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 158 if (mPreview.getDrawable() != null) { 159 // We already have an image, so no need to try to load again. 160 return; 161 } 162 163 if (mCacheLoadFuture != null) { 164 Log.d(TAG, "mCacheLoadFuture != null"); 165 final ListenableFuture<ImageLoader.Result> future = mCacheLoadFuture; 166 mCacheLoadFuture.addListener(() -> { 167 Log.d(TAG, "cached bitmap load complete"); 168 try { 169 onCachedImageLoaded(future.get()); 170 } catch (CancellationException | ExecutionException | InterruptedException e) { 171 Log.e(TAG, "Failed to load cached image", e); 172 if (mSavedImagePath != null) { 173 //noinspection ResultOfMethodCallIgnored 174 mSavedImagePath.delete(); 175 mSavedImagePath = null; 176 } 177 finishAndRemoveTask(); 178 } 179 }, mUiExecutor); 180 mCacheLoadFuture = null; 181 } else { 182 LongScreenshot longScreenshot = mLongScreenshotHolder.takeLongScreenshot(); 183 if (longScreenshot != null) { 184 onLongScreenshotReceived(longScreenshot); 185 } else { 186 Log.e(TAG, "No long screenshot available!"); 187 finishAndRemoveTask(); 188 } 189 } 190 } 191 onLongScreenshotReceived(LongScreenshot longScreenshot)192 private void onLongScreenshotReceived(LongScreenshot longScreenshot) { 193 Log.i(TAG, "Completed: " + longScreenshot); 194 mLongScreenshot = longScreenshot; 195 Drawable drawable = mLongScreenshot.getDrawable(); 196 mPreview.setImageDrawable(drawable); 197 mMagnifierView.setDrawable(mLongScreenshot.getDrawable(), 198 mLongScreenshot.getWidth(), mLongScreenshot.getHeight()); 199 // Original boundaries go from the image tile set's y=0 to y=pageSize, so 200 // we animate to that as a starting crop position. 201 float topFraction = Math.max(0, 202 -mLongScreenshot.getTop() / (float) mLongScreenshot.getHeight()); 203 float bottomFraction = Math.min(1f, 204 1 - (mLongScreenshot.getBottom() - mLongScreenshot.getPageHeight()) 205 / (float) mLongScreenshot.getHeight()); 206 207 mEnterTransitionView.setImageDrawable(drawable); 208 mEnterTransitionView.getViewTreeObserver().addOnPreDrawListener( 209 new ViewTreeObserver.OnPreDrawListener() { 210 @Override 211 public boolean onPreDraw() { 212 mEnterTransitionView.getViewTreeObserver().removeOnPreDrawListener(this); 213 updateImageDimensions(); 214 mEnterTransitionView.post(() -> { 215 Rect dest = new Rect(); 216 mEnterTransitionView.getBoundsOnScreen(dest); 217 mLongScreenshotHolder.takeTransitionDestinationCallback() 218 .setTransitionDestination(dest, () -> { 219 mPreview.animate().alpha(1f); 220 mCropView.setBoundaryPosition( 221 CropView.CropBoundary.TOP, topFraction); 222 mCropView.setBoundaryPosition( 223 CropView.CropBoundary.BOTTOM, bottomFraction); 224 mCropView.animateEntrance(); 225 mCropView.setVisibility(View.VISIBLE); 226 setButtonsEnabled(true); 227 }); 228 }); 229 return true; 230 } 231 }); 232 233 // Immediately export to temp image file for saved state 234 mCacheSaveFuture = mImageExporter.exportToRawFile(mBackgroundExecutor, 235 mLongScreenshot.toBitmap(), new File(getCacheDir(), "long_screenshot_cache.png")); 236 mCacheSaveFuture.addListener(() -> { 237 try { 238 // Get the temp file path to persist, used in onSavedInstanceState 239 mSavedImagePath = mCacheSaveFuture.get(); 240 } catch (CancellationException | InterruptedException | ExecutionException e) { 241 Log.e(TAG, "Error saving temp image file", e); 242 finishAndRemoveTask(); 243 } 244 }, mUiExecutor); 245 } 246 onCachedImageLoaded(ImageLoader.Result imageResult)247 private void onCachedImageLoaded(ImageLoader.Result imageResult) { 248 BitmapDrawable drawable = new BitmapDrawable(getResources(), imageResult.bitmap); 249 mPreview.setImageDrawable(drawable); 250 mPreview.setAlpha(1f); 251 mMagnifierView.setDrawable(drawable, imageResult.bitmap.getWidth(), 252 imageResult.bitmap.getHeight()); 253 mCropView.setVisibility(View.VISIBLE); 254 mSavedImagePath = imageResult.fileName; 255 256 setButtonsEnabled(true); 257 } 258 renderBitmap(Drawable drawable, Rect bounds)259 private static Bitmap renderBitmap(Drawable drawable, Rect bounds) { 260 final RenderNode output = new RenderNode("Bitmap Export"); 261 output.setPosition(0, 0, bounds.width(), bounds.height()); 262 RecordingCanvas canvas = output.beginRecording(); 263 canvas.translate(-bounds.left, -bounds.top); 264 canvas.clipRect(bounds); 265 drawable.draw(canvas); 266 output.endRecording(); 267 return HardwareRenderer.createHardwareBitmap(output, bounds.width(), bounds.height()); 268 } 269 270 @Override onSaveInstanceState(Bundle outState)271 protected void onSaveInstanceState(Bundle outState) { 272 super.onSaveInstanceState(outState); 273 if (mSavedImagePath != null) { 274 outState.putString(KEY_SAVED_IMAGE_PATH, mSavedImagePath.getPath()); 275 } 276 } 277 278 @Override onStop()279 protected void onStop() { 280 super.onStop(); 281 if (mTransitionStarted) { 282 finish(); 283 } 284 if (isFinishing()) { 285 if (mScrollCaptureResponse != null) { 286 mScrollCaptureResponse.close(); 287 } 288 cleanupCache(); 289 290 if (mLongScreenshot != null) { 291 mLongScreenshot.release(); 292 } 293 } 294 } 295 cleanupCache()296 void cleanupCache() { 297 if (mCacheSaveFuture != null) { 298 mCacheSaveFuture.cancel(true); 299 } 300 if (mSavedImagePath != null) { 301 //noinspection ResultOfMethodCallIgnored 302 mSavedImagePath.delete(); 303 mSavedImagePath = null; 304 } 305 } 306 setButtonsEnabled(boolean enabled)307 private void setButtonsEnabled(boolean enabled) { 308 mSave.setEnabled(enabled); 309 mEdit.setEnabled(enabled); 310 mShare.setEnabled(enabled); 311 } 312 doEdit(Uri uri)313 private void doEdit(Uri uri) { 314 String editorPackage = getString(R.string.config_screenshotEditor); 315 Intent intent = new Intent(Intent.ACTION_EDIT); 316 if (!TextUtils.isEmpty(editorPackage)) { 317 intent.setComponent(ComponentName.unflattenFromString(editorPackage)); 318 } 319 intent.setDataAndType(uri, "image/png"); 320 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION 321 | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); 322 323 mTransitionView.setImageBitmap(mOutputBitmap); 324 mTransitionView.setTransitionName( 325 ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME); 326 // TODO: listen for transition completing instead of finishing onStop 327 mTransitionStarted = true; 328 int[] locationOnScreen = new int[2]; 329 mTransitionView.getLocationOnScreen(locationOnScreen); 330 int[] locationInWindow = new int[2]; 331 mTransitionView.getLocationInWindow(locationInWindow); 332 int deltaX = locationOnScreen[0] - locationInWindow[0]; 333 int deltaY = locationOnScreen[1] - locationInWindow[1]; 334 mTransitionView.setX(mTransitionView.getX() - deltaX); 335 mTransitionView.setY(mTransitionView.getY() - deltaY); 336 startActivity(intent, 337 ActivityOptions.makeSceneTransitionAnimation(this, mTransitionView, 338 ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME).toBundle()); 339 } 340 doShare(Uri uri)341 private void doShare(Uri uri) { 342 Intent intent = new Intent(Intent.ACTION_SEND); 343 intent.setType("image/png"); 344 intent.putExtra(Intent.EXTRA_STREAM, uri); 345 intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK 346 | Intent.FLAG_GRANT_READ_URI_PERMISSION); 347 Intent sharingChooserIntent = Intent.createChooser(intent, null) 348 .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); 349 350 startActivityAsUser(sharingChooserIntent, UserHandle.CURRENT); 351 } 352 onClicked(View v)353 private void onClicked(View v) { 354 int id = v.getId(); 355 v.setPressed(true); 356 setButtonsEnabled(false); 357 if (id == R.id.save) { 358 startExport(PendingAction.SAVE); 359 } else if (id == R.id.edit) { 360 mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_LONG_SCREENSHOT_EDIT); 361 startExport(PendingAction.EDIT); 362 } else if (id == R.id.share) { 363 mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_LONG_SCREENSHOT_SHARE); 364 startExport(PendingAction.SHARE); 365 } 366 } 367 startExport(PendingAction action)368 private void startExport(PendingAction action) { 369 Drawable drawable = mPreview.getDrawable(); 370 if (drawable == null) { 371 Log.e(TAG, "No drawable, skipping export!"); 372 return; 373 } 374 375 Rect bounds = mCropView.getCropBoundaries(drawable.getIntrinsicWidth(), 376 drawable.getIntrinsicHeight()); 377 378 if (bounds.isEmpty()) { 379 Log.w(TAG, "Crop bounds empty, skipping export."); 380 return; 381 } 382 383 updateImageDimensions(); 384 385 mOutputBitmap = renderBitmap(drawable, bounds); 386 ListenableFuture<ImageExporter.Result> exportFuture = mImageExporter.export( 387 mBackgroundExecutor, UUID.randomUUID(), mOutputBitmap, ZonedDateTime.now()); 388 exportFuture.addListener(() -> onExportCompleted(action, exportFuture), mUiExecutor); 389 } 390 onExportCompleted(PendingAction action, ListenableFuture<ImageExporter.Result> exportFuture)391 private void onExportCompleted(PendingAction action, 392 ListenableFuture<ImageExporter.Result> exportFuture) { 393 setButtonsEnabled(true); 394 ImageExporter.Result result; 395 try { 396 result = exportFuture.get(); 397 } catch (CancellationException | InterruptedException | ExecutionException e) { 398 Log.e(TAG, "failed to export", e); 399 return; 400 } 401 402 switch (action) { 403 case EDIT: 404 doEdit(result.uri); 405 break; 406 case SHARE: 407 doShare(result.uri); 408 break; 409 case SAVE: 410 // Nothing more to do 411 finishAndRemoveTask(); 412 break; 413 } 414 } 415 updateImageDimensions()416 private void updateImageDimensions() { 417 Drawable drawable = mPreview.getDrawable(); 418 if (drawable == null) { 419 return; 420 } 421 Rect bounds = drawable.getBounds(); 422 float imageRatio = bounds.width() / (float) bounds.height(); 423 int previewWidth = mPreview.getWidth() - mPreview.getPaddingLeft() 424 - mPreview.getPaddingRight(); 425 int previewHeight = mPreview.getHeight() - mPreview.getPaddingTop() 426 - mPreview.getPaddingBottom(); 427 float viewRatio = previewWidth / (float) previewHeight; 428 429 // Top and left offsets of the image relative to mPreview. 430 int imageLeft = mPreview.getPaddingLeft(); 431 int imageTop = mPreview.getPaddingTop(); 432 433 // The image width and height on screen 434 int imageHeight = previewHeight; 435 int imageWidth = previewWidth; 436 float scale; 437 int extraPadding = 0; 438 if (imageRatio > viewRatio) { 439 // Image is full width and height is constrained, compute extra padding to inform 440 // CropView 441 imageHeight = (int) (previewHeight * viewRatio / imageRatio); 442 extraPadding = (previewHeight - imageHeight) / 2; 443 mCropView.setExtraPadding(extraPadding + mPreview.getPaddingTop(), 444 extraPadding + mPreview.getPaddingBottom()); 445 imageTop += (previewHeight - imageHeight) / 2; 446 mCropView.setExtraPadding(extraPadding, extraPadding); 447 mCropView.setImageWidth(previewWidth); 448 scale = previewWidth / (float) mPreview.getDrawable().getIntrinsicWidth(); 449 } else { 450 imageWidth = (int) (previewWidth * imageRatio / viewRatio); 451 imageLeft += (previewWidth - imageWidth) / 2; 452 // Image is full height 453 mCropView.setExtraPadding(mPreview.getPaddingTop(), mPreview.getPaddingBottom()); 454 mCropView.setImageWidth((int) (previewHeight * imageRatio)); 455 scale = previewHeight / (float) mPreview.getDrawable().getIntrinsicHeight(); 456 } 457 458 // Update transition view's position and scale. 459 Rect boundaries = mCropView.getCropBoundaries(imageWidth, imageHeight); 460 mTransitionView.setTranslationX(imageLeft + boundaries.left); 461 mTransitionView.setTranslationY(imageTop + boundaries.top); 462 ConstraintLayout.LayoutParams params = 463 (ConstraintLayout.LayoutParams) mTransitionView.getLayoutParams(); 464 params.width = boundaries.width(); 465 params.height = boundaries.height(); 466 mTransitionView.setLayoutParams(params); 467 468 if (mLongScreenshot != null) { 469 ConstraintLayout.LayoutParams enterTransitionParams = 470 (ConstraintLayout.LayoutParams) mEnterTransitionView.getLayoutParams(); 471 float topFraction = Math.max(0, 472 -mLongScreenshot.getTop() / (float) mLongScreenshot.getHeight()); 473 enterTransitionParams.width = (int) (scale * drawable.getIntrinsicWidth()); 474 enterTransitionParams.height = (int) (scale * mLongScreenshot.getPageHeight()); 475 mEnterTransitionView.setLayoutParams(enterTransitionParams); 476 477 Matrix matrix = new Matrix(); 478 matrix.setScale(scale, scale); 479 matrix.postTranslate(0, -scale * drawable.getIntrinsicHeight() * topFraction); 480 mEnterTransitionView.setImageMatrix(matrix); 481 mEnterTransitionView.setTranslationY( 482 topFraction * previewHeight + mPreview.getPaddingTop() + extraPadding); 483 } 484 } 485 } 486