1 /* 2 * Copyright (C) 2021 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.systemui.screenshot; 18 19 import android.app.Activity; 20 import android.app.ActivityOptions; 21 import android.content.ComponentName; 22 import android.content.Intent; 23 import android.graphics.Bitmap; 24 import android.graphics.HardwareRenderer; 25 import android.graphics.Matrix; 26 import android.graphics.RecordingCanvas; 27 import android.graphics.Rect; 28 import android.graphics.RenderNode; 29 import android.graphics.drawable.BitmapDrawable; 30 import android.graphics.drawable.Drawable; 31 import android.net.Uri; 32 import android.os.Bundle; 33 import android.os.UserHandle; 34 import android.text.TextUtils; 35 import android.util.Log; 36 import android.view.ScrollCaptureResponse; 37 import android.view.View; 38 import android.view.ViewTreeObserver; 39 import android.widget.ImageView; 40 41 import androidx.constraintlayout.widget.ConstraintLayout; 42 43 import com.android.internal.app.ChooserActivity; 44 import com.android.internal.logging.UiEventLogger; 45 import com.android.systemui.R; 46 import com.android.systemui.dagger.qualifiers.Background; 47 import com.android.systemui.dagger.qualifiers.Main; 48 import com.android.systemui.screenshot.ScrollCaptureController.LongScreenshot; 49 50 import com.google.common.util.concurrent.ListenableFuture; 51 52 import java.io.File; 53 import java.time.ZonedDateTime; 54 import java.util.UUID; 55 import java.util.concurrent.CancellationException; 56 import java.util.concurrent.ExecutionException; 57 import java.util.concurrent.Executor; 58 59 import javax.inject.Inject; 60 61 /** 62 * LongScreenshotActivity acquires bitmap data for a long screenshot and lets the user trim the top 63 * and bottom before saving/sharing/editing. 64 */ 65 public class LongScreenshotActivity extends Activity { 66 private static final String TAG = LogConfig.logTag(LongScreenshotActivity.class); 67 68 public static final String EXTRA_CAPTURE_RESPONSE = "capture-response"; 69 private static final String KEY_SAVED_IMAGE_PATH = "saved-image-path"; 70 71 private final UiEventLogger mUiEventLogger; 72 private final Executor mUiExecutor; 73 private final Executor mBackgroundExecutor; 74 private final ImageExporter mImageExporter; 75 private final LongScreenshotData mLongScreenshotHolder; 76 77 private ImageView mPreview; 78 private ImageView mTransitionView; 79 private ImageView mEnterTransitionView; 80 private View mSave; 81 private View mEdit; 82 private View mShare; 83 private CropView mCropView; 84 private MagnifierView mMagnifierView; 85 private ScrollCaptureResponse mScrollCaptureResponse; 86 private File mSavedImagePath; 87 88 private ListenableFuture<File> mCacheSaveFuture; 89 private ListenableFuture<ImageLoader.Result> mCacheLoadFuture; 90 91 private Bitmap mOutputBitmap; 92 private LongScreenshot mLongScreenshot; 93 private boolean mTransitionStarted; 94 95 private enum PendingAction { 96 SHARE, 97 EDIT, 98 SAVE 99 } 100 101 @Inject LongScreenshotActivity(UiEventLogger uiEventLogger, ImageExporter imageExporter, @Main Executor mainExecutor, @Background Executor bgExecutor, LongScreenshotData longScreenshotHolder)102 public LongScreenshotActivity(UiEventLogger uiEventLogger, ImageExporter imageExporter, 103 @Main Executor mainExecutor, @Background Executor bgExecutor, 104 LongScreenshotData longScreenshotHolder) { 105 mUiEventLogger = uiEventLogger; 106 mUiExecutor = mainExecutor; 107 mBackgroundExecutor = bgExecutor; 108 mImageExporter = imageExporter; 109 mLongScreenshotHolder = longScreenshotHolder; 110 } 111 112 113 @Override onCreate(Bundle savedInstanceState)114 public void onCreate(Bundle savedInstanceState) { 115 super.onCreate(savedInstanceState); 116 setContentView(R.layout.long_screenshot); 117 118 mPreview = requireViewById(R.id.preview); 119 mSave = requireViewById(R.id.save); 120 mEdit = requireViewById(R.id.edit); 121 mShare = requireViewById(R.id.share); 122 mCropView = requireViewById(R.id.crop_view); 123 mMagnifierView = requireViewById(R.id.magnifier); 124 mCropView.setCropInteractionListener(mMagnifierView); 125 mTransitionView = requireViewById(R.id.transition); 126 mEnterTransitionView = requireViewById(R.id.enter_transition); 127 128 requireViewById(R.id.cancel).setOnClickListener(v -> finishAndRemoveTask()); 129 130 mSave.setOnClickListener(this::onClicked); 131 mEdit.setOnClickListener(this::onClicked); 132 mShare.setOnClickListener(this::onClicked); 133 134 mPreview.addOnLayoutChangeListener( 135 (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> 136 updateImageDimensions()); 137 138 Intent intent = getIntent(); 139 mScrollCaptureResponse = intent.getParcelableExtra(EXTRA_CAPTURE_RESPONSE); 140 141 if (savedInstanceState != null) { 142 String savedImagePath = savedInstanceState.getString(KEY_SAVED_IMAGE_PATH); 143 if (savedImagePath == null) { 144 Log.e(TAG, "Missing saved state entry with key '" + KEY_SAVED_IMAGE_PATH + "'!"); 145 finishAndRemoveTask(); 146 return; 147 } 148 mSavedImagePath = new File(savedImagePath); 149 ImageLoader imageLoader = new ImageLoader(getContentResolver()); 150 mCacheLoadFuture = imageLoader.load(mSavedImagePath); 151 } 152 } 153 154 @Override onStart()155 public void onStart() { 156 super.onStart(); 157 mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_LONG_SCREENSHOT_ACTIVITY_STARTED); 158 159 if (mPreview.getDrawable() != null) { 160 // We already have an image, so no need to try to load again. 161 return; 162 } 163 164 if (mCacheLoadFuture != null) { 165 Log.d(TAG, "mCacheLoadFuture != null"); 166 final ListenableFuture<ImageLoader.Result> future = mCacheLoadFuture; 167 mCacheLoadFuture.addListener(() -> { 168 Log.d(TAG, "cached bitmap load complete"); 169 try { 170 onCachedImageLoaded(future.get()); 171 } catch (CancellationException | ExecutionException | InterruptedException e) { 172 Log.e(TAG, "Failed to load cached image", e); 173 if (mSavedImagePath != null) { 174 //noinspection ResultOfMethodCallIgnored 175 mSavedImagePath.delete(); 176 mSavedImagePath = null; 177 } 178 finishAndRemoveTask(); 179 } 180 }, mUiExecutor); 181 mCacheLoadFuture = null; 182 } else { 183 LongScreenshot longScreenshot = mLongScreenshotHolder.takeLongScreenshot(); 184 if (longScreenshot != null) { 185 onLongScreenshotReceived(longScreenshot); 186 } else { 187 Log.e(TAG, "No long screenshot available!"); 188 finishAndRemoveTask(); 189 } 190 } 191 } 192 onLongScreenshotReceived(LongScreenshot longScreenshot)193 private void onLongScreenshotReceived(LongScreenshot longScreenshot) { 194 Log.i(TAG, "Completed: " + longScreenshot); 195 mLongScreenshot = longScreenshot; 196 Drawable drawable = mLongScreenshot.getDrawable(); 197 mPreview.setImageDrawable(drawable); 198 mMagnifierView.setDrawable(mLongScreenshot.getDrawable(), 199 mLongScreenshot.getWidth(), mLongScreenshot.getHeight()); 200 // Original boundaries go from the image tile set's y=0 to y=pageSize, so 201 // we animate to that as a starting crop position. 202 float topFraction = Math.max(0, 203 -mLongScreenshot.getTop() / (float) mLongScreenshot.getHeight()); 204 float bottomFraction = Math.min(1f, 205 1 - (mLongScreenshot.getBottom() - mLongScreenshot.getPageHeight()) 206 / (float) mLongScreenshot.getHeight()); 207 208 mEnterTransitionView.setImageDrawable(drawable); 209 mEnterTransitionView.getViewTreeObserver().addOnPreDrawListener( 210 new ViewTreeObserver.OnPreDrawListener() { 211 @Override 212 public boolean onPreDraw() { 213 mEnterTransitionView.getViewTreeObserver().removeOnPreDrawListener(this); 214 updateImageDimensions(); 215 mEnterTransitionView.post(() -> { 216 Rect dest = new Rect(); 217 mEnterTransitionView.getBoundsOnScreen(dest); 218 mLongScreenshotHolder.takeTransitionDestinationCallback() 219 .setTransitionDestination(dest, () -> { 220 mPreview.animate().alpha(1f); 221 mCropView.setBoundaryPosition( 222 CropView.CropBoundary.TOP, topFraction); 223 mCropView.setBoundaryPosition( 224 CropView.CropBoundary.BOTTOM, bottomFraction); 225 mCropView.animateEntrance(); 226 mCropView.setVisibility(View.VISIBLE); 227 setButtonsEnabled(true); 228 }); 229 }); 230 return true; 231 } 232 }); 233 234 // Immediately export to temp image file for saved state 235 mCacheSaveFuture = mImageExporter.exportToRawFile(mBackgroundExecutor, 236 mLongScreenshot.toBitmap(), new File(getCacheDir(), "long_screenshot_cache.png")); 237 mCacheSaveFuture.addListener(() -> { 238 try { 239 // Get the temp file path to persist, used in onSavedInstanceState 240 mSavedImagePath = mCacheSaveFuture.get(); 241 } catch (CancellationException | InterruptedException | ExecutionException e) { 242 Log.e(TAG, "Error saving temp image file", e); 243 finishAndRemoveTask(); 244 } 245 }, mUiExecutor); 246 } 247 onCachedImageLoaded(ImageLoader.Result imageResult)248 private void onCachedImageLoaded(ImageLoader.Result imageResult) { 249 mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_LONG_SCREENSHOT_ACTIVITY_CACHED_IMAGE_LOADED); 250 251 BitmapDrawable drawable = new BitmapDrawable(getResources(), imageResult.bitmap); 252 mPreview.setImageDrawable(drawable); 253 mPreview.setAlpha(1f); 254 mMagnifierView.setDrawable(drawable, imageResult.bitmap.getWidth(), 255 imageResult.bitmap.getHeight()); 256 mCropView.setVisibility(View.VISIBLE); 257 mSavedImagePath = imageResult.fileName; 258 259 setButtonsEnabled(true); 260 } 261 renderBitmap(Drawable drawable, Rect bounds)262 private static Bitmap renderBitmap(Drawable drawable, Rect bounds) { 263 final RenderNode output = new RenderNode("Bitmap Export"); 264 output.setPosition(0, 0, bounds.width(), bounds.height()); 265 RecordingCanvas canvas = output.beginRecording(); 266 canvas.translate(-bounds.left, -bounds.top); 267 canvas.clipRect(bounds); 268 drawable.draw(canvas); 269 output.endRecording(); 270 return HardwareRenderer.createHardwareBitmap(output, bounds.width(), bounds.height()); 271 } 272 273 @Override onSaveInstanceState(Bundle outState)274 protected void onSaveInstanceState(Bundle outState) { 275 super.onSaveInstanceState(outState); 276 if (mSavedImagePath != null) { 277 outState.putString(KEY_SAVED_IMAGE_PATH, mSavedImagePath.getPath()); 278 } 279 } 280 281 @Override onStop()282 protected void onStop() { 283 super.onStop(); 284 if (mTransitionStarted) { 285 finish(); 286 } 287 if (isFinishing()) { 288 mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_LONG_SCREENSHOT_ACTIVITY_FINISHED); 289 290 if (mScrollCaptureResponse != null) { 291 mScrollCaptureResponse.close(); 292 } 293 cleanupCache(); 294 295 if (mLongScreenshot != null) { 296 mLongScreenshot.release(); 297 } 298 } 299 } 300 cleanupCache()301 void cleanupCache() { 302 if (mCacheSaveFuture != null) { 303 mCacheSaveFuture.cancel(true); 304 } 305 if (mSavedImagePath != null) { 306 //noinspection ResultOfMethodCallIgnored 307 mSavedImagePath.delete(); 308 mSavedImagePath = null; 309 } 310 } 311 setButtonsEnabled(boolean enabled)312 private void setButtonsEnabled(boolean enabled) { 313 mSave.setEnabled(enabled); 314 mEdit.setEnabled(enabled); 315 mShare.setEnabled(enabled); 316 } 317 doEdit(Uri uri)318 private void doEdit(Uri uri) { 319 String editorPackage = getString(R.string.config_screenshotEditor); 320 Intent intent = new Intent(Intent.ACTION_EDIT); 321 if (!TextUtils.isEmpty(editorPackage)) { 322 intent.setComponent(ComponentName.unflattenFromString(editorPackage)); 323 } 324 intent.setDataAndType(uri, "image/png"); 325 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION 326 | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); 327 328 mTransitionView.setImageBitmap(mOutputBitmap); 329 mTransitionView.setVisibility(View.VISIBLE); 330 mTransitionView.setTransitionName( 331 ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME); 332 // TODO: listen for transition completing instead of finishing onStop 333 mTransitionStarted = true; 334 startActivity(intent, 335 ActivityOptions.makeSceneTransitionAnimation(this, mTransitionView, 336 ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME).toBundle()); 337 } 338 doShare(Uri uri)339 private void doShare(Uri uri) { 340 Intent intent = new Intent(Intent.ACTION_SEND); 341 intent.setType("image/png"); 342 intent.putExtra(Intent.EXTRA_STREAM, uri); 343 intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK 344 | Intent.FLAG_GRANT_READ_URI_PERMISSION); 345 Intent sharingChooserIntent = Intent.createChooser(intent, null) 346 .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); 347 348 startActivityAsUser(sharingChooserIntent, UserHandle.CURRENT); 349 } 350 onClicked(View v)351 private void onClicked(View v) { 352 int id = v.getId(); 353 v.setPressed(true); 354 setButtonsEnabled(false); 355 if (id == R.id.save) { 356 startExport(PendingAction.SAVE); 357 } else if (id == R.id.edit) { 358 mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_LONG_SCREENSHOT_EDIT); 359 startExport(PendingAction.EDIT); 360 } else if (id == R.id.share) { 361 mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_LONG_SCREENSHOT_SHARE); 362 startExport(PendingAction.SHARE); 363 } 364 } 365 startExport(PendingAction action)366 private void startExport(PendingAction action) { 367 Drawable drawable = mPreview.getDrawable(); 368 if (drawable == null) { 369 Log.e(TAG, "No drawable, skipping export!"); 370 return; 371 } 372 373 Rect bounds = mCropView.getCropBoundaries(drawable.getIntrinsicWidth(), 374 drawable.getIntrinsicHeight()); 375 376 if (bounds.isEmpty()) { 377 Log.w(TAG, "Crop bounds empty, skipping export."); 378 return; 379 } 380 381 updateImageDimensions(); 382 383 mOutputBitmap = renderBitmap(drawable, bounds); 384 ListenableFuture<ImageExporter.Result> exportFuture = mImageExporter.export( 385 mBackgroundExecutor, UUID.randomUUID(), mOutputBitmap, ZonedDateTime.now()); 386 exportFuture.addListener(() -> onExportCompleted(action, exportFuture), mUiExecutor); 387 } 388 onExportCompleted(PendingAction action, ListenableFuture<ImageExporter.Result> exportFuture)389 private void onExportCompleted(PendingAction action, 390 ListenableFuture<ImageExporter.Result> exportFuture) { 391 setButtonsEnabled(true); 392 ImageExporter.Result result; 393 try { 394 result = exportFuture.get(); 395 } catch (CancellationException | InterruptedException | ExecutionException e) { 396 Log.e(TAG, "failed to export", e); 397 return; 398 } 399 400 switch (action) { 401 case EDIT: 402 doEdit(result.uri); 403 break; 404 case SHARE: 405 doShare(result.uri); 406 break; 407 case SAVE: 408 // Nothing more to do 409 finishAndRemoveTask(); 410 break; 411 } 412 } 413 updateImageDimensions()414 private void updateImageDimensions() { 415 Drawable drawable = mPreview.getDrawable(); 416 if (drawable == null) { 417 return; 418 } 419 Rect bounds = drawable.getBounds(); 420 float imageRatio = bounds.width() / (float) bounds.height(); 421 int previewWidth = mPreview.getWidth() - mPreview.getPaddingLeft() 422 - mPreview.getPaddingRight(); 423 int previewHeight = mPreview.getHeight() - mPreview.getPaddingTop() 424 - mPreview.getPaddingBottom(); 425 float viewRatio = previewWidth / (float) previewHeight; 426 427 // Top and left offsets of the image relative to mPreview. 428 int imageLeft = mPreview.getPaddingLeft(); 429 int imageTop = mPreview.getPaddingTop(); 430 431 // The image width and height on screen 432 int imageHeight = previewHeight; 433 int imageWidth = previewWidth; 434 float scale; 435 int extraPadding = 0; 436 if (imageRatio > viewRatio) { 437 // Image is full width and height is constrained, compute extra padding to inform 438 // CropView 439 imageHeight = (int) (previewHeight * viewRatio / imageRatio); 440 extraPadding = (previewHeight - imageHeight) / 2; 441 mCropView.setExtraPadding(extraPadding + mPreview.getPaddingTop(), 442 extraPadding + mPreview.getPaddingBottom()); 443 imageTop += (previewHeight - imageHeight) / 2; 444 mCropView.setExtraPadding(extraPadding, extraPadding); 445 mCropView.setImageWidth(previewWidth); 446 scale = previewWidth / (float) mPreview.getDrawable().getIntrinsicWidth(); 447 } else { 448 imageWidth = (int) (previewWidth * imageRatio / viewRatio); 449 imageLeft += (previewWidth - imageWidth) / 2; 450 // Image is full height 451 mCropView.setExtraPadding(mPreview.getPaddingTop(), mPreview.getPaddingBottom()); 452 mCropView.setImageWidth((int) (previewHeight * imageRatio)); 453 scale = previewHeight / (float) mPreview.getDrawable().getIntrinsicHeight(); 454 } 455 456 // Update transition view's position and scale. 457 Rect boundaries = mCropView.getCropBoundaries(imageWidth, imageHeight); 458 mTransitionView.setTranslationX(imageLeft + boundaries.left); 459 mTransitionView.setTranslationY(imageTop + boundaries.top); 460 ConstraintLayout.LayoutParams params = 461 (ConstraintLayout.LayoutParams) mTransitionView.getLayoutParams(); 462 params.width = boundaries.width(); 463 params.height = boundaries.height(); 464 mTransitionView.setLayoutParams(params); 465 466 if (mLongScreenshot != null) { 467 ConstraintLayout.LayoutParams enterTransitionParams = 468 (ConstraintLayout.LayoutParams) mEnterTransitionView.getLayoutParams(); 469 float topFraction = Math.max(0, 470 -mLongScreenshot.getTop() / (float) mLongScreenshot.getHeight()); 471 enterTransitionParams.width = (int) (scale * drawable.getIntrinsicWidth()); 472 enterTransitionParams.height = (int) (scale * mLongScreenshot.getPageHeight()); 473 mEnterTransitionView.setLayoutParams(enterTransitionParams); 474 475 Matrix matrix = new Matrix(); 476 matrix.setScale(scale, scale); 477 matrix.postTranslate(0, -scale * drawable.getIntrinsicHeight() * topFraction); 478 mEnterTransitionView.setImageMatrix(matrix); 479 mEnterTransitionView.setTranslationY( 480 topFraction * previewHeight + mPreview.getPaddingTop() + extraPadding); 481 } 482 } 483 } 484