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