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