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