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