/* * Copyright (C) 2021 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.systemui.screenshot; import android.app.Activity; import android.app.ActivityOptions; import android.content.ComponentName; import android.content.Intent; import android.graphics.Bitmap; import android.graphics.HardwareRenderer; import android.graphics.Matrix; import android.graphics.RecordingCanvas; import android.graphics.Rect; import android.graphics.RenderNode; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; import android.os.UserHandle; import android.text.TextUtils; import android.util.Log; import android.view.ScrollCaptureResponse; import android.view.View; import android.view.ViewTreeObserver; import android.widget.ImageView; import androidx.constraintlayout.widget.ConstraintLayout; import com.android.internal.app.ChooserActivity; import com.android.internal.logging.UiEventLogger; import com.android.systemui.R; import com.android.systemui.dagger.qualifiers.Background; import com.android.systemui.dagger.qualifiers.Main; import com.android.systemui.screenshot.ScrollCaptureController.LongScreenshot; import com.google.common.util.concurrent.ListenableFuture; import java.io.File; import java.time.ZonedDateTime; import java.util.UUID; import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; import javax.inject.Inject; /** * LongScreenshotActivity acquires bitmap data for a long screenshot and lets the user trim the top * and bottom before saving/sharing/editing. */ public class LongScreenshotActivity extends Activity { private static final String TAG = LogConfig.logTag(LongScreenshotActivity.class); public static final String EXTRA_CAPTURE_RESPONSE = "capture-response"; private static final String KEY_SAVED_IMAGE_PATH = "saved-image-path"; private final UiEventLogger mUiEventLogger; private final Executor mUiExecutor; private final Executor mBackgroundExecutor; private final ImageExporter mImageExporter; private final LongScreenshotData mLongScreenshotHolder; private ImageView mPreview; private ImageView mTransitionView; private ImageView mEnterTransitionView; private View mSave; private View mEdit; private View mShare; private CropView mCropView; private MagnifierView mMagnifierView; private ScrollCaptureResponse mScrollCaptureResponse; private File mSavedImagePath; private ListenableFuture mCacheSaveFuture; private ListenableFuture mCacheLoadFuture; private Bitmap mOutputBitmap; private LongScreenshot mLongScreenshot; private boolean mTransitionStarted; private enum PendingAction { SHARE, EDIT, SAVE } @Inject public LongScreenshotActivity(UiEventLogger uiEventLogger, ImageExporter imageExporter, @Main Executor mainExecutor, @Background Executor bgExecutor, LongScreenshotData longScreenshotHolder) { mUiEventLogger = uiEventLogger; mUiExecutor = mainExecutor; mBackgroundExecutor = bgExecutor; mImageExporter = imageExporter; mLongScreenshotHolder = longScreenshotHolder; } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.long_screenshot); mPreview = requireViewById(R.id.preview); mSave = requireViewById(R.id.save); mEdit = requireViewById(R.id.edit); mShare = requireViewById(R.id.share); mCropView = requireViewById(R.id.crop_view); mMagnifierView = requireViewById(R.id.magnifier); mCropView.setCropInteractionListener(mMagnifierView); mTransitionView = requireViewById(R.id.transition); mEnterTransitionView = requireViewById(R.id.enter_transition); requireViewById(R.id.cancel).setOnClickListener(v -> finishAndRemoveTask()); mSave.setOnClickListener(this::onClicked); mEdit.setOnClickListener(this::onClicked); mShare.setOnClickListener(this::onClicked); mPreview.addOnLayoutChangeListener( (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> updateImageDimensions()); Intent intent = getIntent(); mScrollCaptureResponse = intent.getParcelableExtra(EXTRA_CAPTURE_RESPONSE); if (savedInstanceState != null) { String savedImagePath = savedInstanceState.getString(KEY_SAVED_IMAGE_PATH); if (savedImagePath == null) { Log.e(TAG, "Missing saved state entry with key '" + KEY_SAVED_IMAGE_PATH + "'!"); finishAndRemoveTask(); return; } mSavedImagePath = new File(savedImagePath); ImageLoader imageLoader = new ImageLoader(getContentResolver()); mCacheLoadFuture = imageLoader.load(mSavedImagePath); } } @Override public void onStart() { super.onStart(); if (mPreview.getDrawable() != null) { // We already have an image, so no need to try to load again. return; } if (mCacheLoadFuture != null) { Log.d(TAG, "mCacheLoadFuture != null"); final ListenableFuture future = mCacheLoadFuture; mCacheLoadFuture.addListener(() -> { Log.d(TAG, "cached bitmap load complete"); try { onCachedImageLoaded(future.get()); } catch (CancellationException | ExecutionException | InterruptedException e) { Log.e(TAG, "Failed to load cached image", e); if (mSavedImagePath != null) { //noinspection ResultOfMethodCallIgnored mSavedImagePath.delete(); mSavedImagePath = null; } finishAndRemoveTask(); } }, mUiExecutor); mCacheLoadFuture = null; } else { LongScreenshot longScreenshot = mLongScreenshotHolder.takeLongScreenshot(); if (longScreenshot != null) { onLongScreenshotReceived(longScreenshot); } else { Log.e(TAG, "No long screenshot available!"); finishAndRemoveTask(); } } } private void onLongScreenshotReceived(LongScreenshot longScreenshot) { Log.i(TAG, "Completed: " + longScreenshot); mLongScreenshot = longScreenshot; Drawable drawable = mLongScreenshot.getDrawable(); mPreview.setImageDrawable(drawable); mMagnifierView.setDrawable(mLongScreenshot.getDrawable(), mLongScreenshot.getWidth(), mLongScreenshot.getHeight()); // Original boundaries go from the image tile set's y=0 to y=pageSize, so // we animate to that as a starting crop position. float topFraction = Math.max(0, -mLongScreenshot.getTop() / (float) mLongScreenshot.getHeight()); float bottomFraction = Math.min(1f, 1 - (mLongScreenshot.getBottom() - mLongScreenshot.getPageHeight()) / (float) mLongScreenshot.getHeight()); mEnterTransitionView.setImageDrawable(drawable); mEnterTransitionView.getViewTreeObserver().addOnPreDrawListener( new ViewTreeObserver.OnPreDrawListener() { @Override public boolean onPreDraw() { mEnterTransitionView.getViewTreeObserver().removeOnPreDrawListener(this); updateImageDimensions(); mEnterTransitionView.post(() -> { Rect dest = new Rect(); mEnterTransitionView.getBoundsOnScreen(dest); mLongScreenshotHolder.takeTransitionDestinationCallback() .setTransitionDestination(dest, () -> { mPreview.animate().alpha(1f); mCropView.setBoundaryPosition( CropView.CropBoundary.TOP, topFraction); mCropView.setBoundaryPosition( CropView.CropBoundary.BOTTOM, bottomFraction); mCropView.animateEntrance(); mCropView.setVisibility(View.VISIBLE); setButtonsEnabled(true); }); }); return true; } }); // Immediately export to temp image file for saved state mCacheSaveFuture = mImageExporter.exportToRawFile(mBackgroundExecutor, mLongScreenshot.toBitmap(), new File(getCacheDir(), "long_screenshot_cache.png")); mCacheSaveFuture.addListener(() -> { try { // Get the temp file path to persist, used in onSavedInstanceState mSavedImagePath = mCacheSaveFuture.get(); } catch (CancellationException | InterruptedException | ExecutionException e) { Log.e(TAG, "Error saving temp image file", e); finishAndRemoveTask(); } }, mUiExecutor); } private void onCachedImageLoaded(ImageLoader.Result imageResult) { BitmapDrawable drawable = new BitmapDrawable(getResources(), imageResult.bitmap); mPreview.setImageDrawable(drawable); mPreview.setAlpha(1f); mMagnifierView.setDrawable(drawable, imageResult.bitmap.getWidth(), imageResult.bitmap.getHeight()); mCropView.setVisibility(View.VISIBLE); mSavedImagePath = imageResult.fileName; setButtonsEnabled(true); } private static Bitmap renderBitmap(Drawable drawable, Rect bounds) { final RenderNode output = new RenderNode("Bitmap Export"); output.setPosition(0, 0, bounds.width(), bounds.height()); RecordingCanvas canvas = output.beginRecording(); canvas.translate(-bounds.left, -bounds.top); canvas.clipRect(bounds); drawable.draw(canvas); output.endRecording(); return HardwareRenderer.createHardwareBitmap(output, bounds.width(), bounds.height()); } @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); if (mSavedImagePath != null) { outState.putString(KEY_SAVED_IMAGE_PATH, mSavedImagePath.getPath()); } } @Override protected void onStop() { super.onStop(); if (mTransitionStarted) { finish(); } if (isFinishing()) { if (mScrollCaptureResponse != null) { mScrollCaptureResponse.close(); } cleanupCache(); if (mLongScreenshot != null) { mLongScreenshot.release(); } } } void cleanupCache() { if (mCacheSaveFuture != null) { mCacheSaveFuture.cancel(true); } if (mSavedImagePath != null) { //noinspection ResultOfMethodCallIgnored mSavedImagePath.delete(); mSavedImagePath = null; } } private void setButtonsEnabled(boolean enabled) { mSave.setEnabled(enabled); mEdit.setEnabled(enabled); mShare.setEnabled(enabled); } private void doEdit(Uri uri) { String editorPackage = getString(R.string.config_screenshotEditor); Intent intent = new Intent(Intent.ACTION_EDIT); if (!TextUtils.isEmpty(editorPackage)) { intent.setComponent(ComponentName.unflattenFromString(editorPackage)); } intent.setDataAndType(uri, "image/png"); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); mTransitionView.setImageBitmap(mOutputBitmap); mTransitionView.setTransitionName( ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME); // TODO: listen for transition completing instead of finishing onStop mTransitionStarted = true; int[] locationOnScreen = new int[2]; mTransitionView.getLocationOnScreen(locationOnScreen); int[] locationInWindow = new int[2]; mTransitionView.getLocationInWindow(locationInWindow); int deltaX = locationOnScreen[0] - locationInWindow[0]; int deltaY = locationOnScreen[1] - locationInWindow[1]; mTransitionView.setX(mTransitionView.getX() - deltaX); mTransitionView.setY(mTransitionView.getY() - deltaY); startActivity(intent, ActivityOptions.makeSceneTransitionAnimation(this, mTransitionView, ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME).toBundle()); } private void doShare(Uri uri) { Intent intent = new Intent(Intent.ACTION_SEND); intent.setType("image/png"); intent.putExtra(Intent.EXTRA_STREAM, uri); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_GRANT_READ_URI_PERMISSION); Intent sharingChooserIntent = Intent.createChooser(intent, null) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); startActivityAsUser(sharingChooserIntent, UserHandle.CURRENT); } private void onClicked(View v) { int id = v.getId(); v.setPressed(true); setButtonsEnabled(false); if (id == R.id.save) { startExport(PendingAction.SAVE); } else if (id == R.id.edit) { mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_LONG_SCREENSHOT_EDIT); startExport(PendingAction.EDIT); } else if (id == R.id.share) { mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_LONG_SCREENSHOT_SHARE); startExport(PendingAction.SHARE); } } private void startExport(PendingAction action) { Drawable drawable = mPreview.getDrawable(); if (drawable == null) { Log.e(TAG, "No drawable, skipping export!"); return; } Rect bounds = mCropView.getCropBoundaries(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()); if (bounds.isEmpty()) { Log.w(TAG, "Crop bounds empty, skipping export."); return; } updateImageDimensions(); mOutputBitmap = renderBitmap(drawable, bounds); ListenableFuture exportFuture = mImageExporter.export( mBackgroundExecutor, UUID.randomUUID(), mOutputBitmap, ZonedDateTime.now()); exportFuture.addListener(() -> onExportCompleted(action, exportFuture), mUiExecutor); } private void onExportCompleted(PendingAction action, ListenableFuture exportFuture) { setButtonsEnabled(true); ImageExporter.Result result; try { result = exportFuture.get(); } catch (CancellationException | InterruptedException | ExecutionException e) { Log.e(TAG, "failed to export", e); return; } switch (action) { case EDIT: doEdit(result.uri); break; case SHARE: doShare(result.uri); break; case SAVE: // Nothing more to do finishAndRemoveTask(); break; } } private void updateImageDimensions() { Drawable drawable = mPreview.getDrawable(); if (drawable == null) { return; } Rect bounds = drawable.getBounds(); float imageRatio = bounds.width() / (float) bounds.height(); int previewWidth = mPreview.getWidth() - mPreview.getPaddingLeft() - mPreview.getPaddingRight(); int previewHeight = mPreview.getHeight() - mPreview.getPaddingTop() - mPreview.getPaddingBottom(); float viewRatio = previewWidth / (float) previewHeight; // Top and left offsets of the image relative to mPreview. int imageLeft = mPreview.getPaddingLeft(); int imageTop = mPreview.getPaddingTop(); // The image width and height on screen int imageHeight = previewHeight; int imageWidth = previewWidth; float scale; int extraPadding = 0; if (imageRatio > viewRatio) { // Image is full width and height is constrained, compute extra padding to inform // CropView imageHeight = (int) (previewHeight * viewRatio / imageRatio); extraPadding = (previewHeight - imageHeight) / 2; mCropView.setExtraPadding(extraPadding + mPreview.getPaddingTop(), extraPadding + mPreview.getPaddingBottom()); imageTop += (previewHeight - imageHeight) / 2; mCropView.setExtraPadding(extraPadding, extraPadding); mCropView.setImageWidth(previewWidth); scale = previewWidth / (float) mPreview.getDrawable().getIntrinsicWidth(); } else { imageWidth = (int) (previewWidth * imageRatio / viewRatio); imageLeft += (previewWidth - imageWidth) / 2; // Image is full height mCropView.setExtraPadding(mPreview.getPaddingTop(), mPreview.getPaddingBottom()); mCropView.setImageWidth((int) (previewHeight * imageRatio)); scale = previewHeight / (float) mPreview.getDrawable().getIntrinsicHeight(); } // Update transition view's position and scale. Rect boundaries = mCropView.getCropBoundaries(imageWidth, imageHeight); mTransitionView.setTranslationX(imageLeft + boundaries.left); mTransitionView.setTranslationY(imageTop + boundaries.top); ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) mTransitionView.getLayoutParams(); params.width = boundaries.width(); params.height = boundaries.height(); mTransitionView.setLayoutParams(params); if (mLongScreenshot != null) { ConstraintLayout.LayoutParams enterTransitionParams = (ConstraintLayout.LayoutParams) mEnterTransitionView.getLayoutParams(); float topFraction = Math.max(0, -mLongScreenshot.getTop() / (float) mLongScreenshot.getHeight()); enterTransitionParams.width = (int) (scale * drawable.getIntrinsicWidth()); enterTransitionParams.height = (int) (scale * mLongScreenshot.getPageHeight()); mEnterTransitionView.setLayoutParams(enterTransitionParams); Matrix matrix = new Matrix(); matrix.setScale(scale, scale); matrix.postTranslate(0, -scale * drawable.getIntrinsicHeight() * topFraction); mEnterTransitionView.setImageMatrix(matrix); mEnterTransitionView.setTranslationY( topFraction * previewHeight + mPreview.getPaddingTop() + extraPadding); } } }