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