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