blob: 0eaef72ae29b239905b2f26fe2441b221db7f5b0 [file] [log] [blame]
/*
* 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<File> mCacheSaveFuture;
private ListenableFuture<ImageLoader.Result> 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<ImageLoader.Result> 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<ImageExporter.Result> exportFuture = mImageExporter.export(
mBackgroundExecutor, UUID.randomUUID(), mOutputBitmap, ZonedDateTime.now());
exportFuture.addListener(() -> onExportCompleted(action, exportFuture), mUiExecutor);
}
private void onExportCompleted(PendingAction action,
ListenableFuture<ImageExporter.Result> 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);
}
}
}