blob: 1e916739ca9b5dfed59ecfcf6660b6f4befbb3f7 [file] [log] [blame]
/*
* Copyright (C) 2020 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 static android.content.res.Configuration.ORIENTATION_LANDSCAPE;
import static android.content.res.Configuration.ORIENTATION_PORTRAIT;
import static android.view.Display.DEFAULT_DISPLAY;
import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
import static android.view.WindowManager.LayoutParams.TYPE_SCREENSHOT;
import static java.util.Objects.requireNonNull;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.annotation.Nullable;
import android.annotation.SuppressLint;
import android.app.Notification;
import android.content.ComponentName;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Bitmap;
import android.graphics.Insets;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.hardware.display.DisplayManager;
import android.media.MediaActionSound;
import android.net.Uri;
import android.os.Binder;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.Message;
import android.provider.DeviceConfig;
import android.provider.Settings;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.Display;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.SurfaceControl;
import android.view.View;
import android.view.Window;
import android.view.WindowInsets;
import android.view.WindowManager;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityManager;
import android.widget.Toast;
import com.android.internal.config.sysui.SystemUiDeviceConfigFlags;
import com.android.internal.logging.UiEventLogger;
import com.android.internal.policy.PhoneWindow;
import com.android.systemui.R;
import com.android.systemui.util.DeviceConfigProxy;
import java.util.List;
import java.util.function.Consumer;
import javax.inject.Inject;
/**
* Controls the state and flow for screenshots.
*/
public class ScreenshotController {
/**
* POD used in the AsyncTask which saves an image in the background.
*/
static class SaveImageInBackgroundData {
public Bitmap image;
public Consumer<Uri> finisher;
public ScreenshotController.ActionsReadyListener mActionsReadyListener;
void clearImage() {
image = null;
}
}
/**
* Structure returned by the SaveImageInBackgroundTask
*/
static class SavedImageData {
public Uri uri;
public Notification.Action shareAction;
public Notification.Action editAction;
public Notification.Action deleteAction;
public List<Notification.Action> smartActions;
/**
* Used to reset the return data on error
*/
public void reset() {
uri = null;
shareAction = null;
editAction = null;
deleteAction = null;
smartActions = null;
}
}
abstract static class ActionsReadyListener {
abstract void onActionsReady(ScreenshotController.SavedImageData imageData);
}
private static final String TAG = "ScreenshotController";
// These strings are used for communicating the action invoked to
// ScreenshotNotificationSmartActionsProvider.
static final String EXTRA_ACTION_TYPE = "android:screenshot_action_type";
static final String EXTRA_ID = "android:screenshot_id";
static final String ACTION_TYPE_DELETE = "Delete";
static final String ACTION_TYPE_SHARE = "Share";
static final String ACTION_TYPE_EDIT = "Edit";
static final String EXTRA_SMART_ACTIONS_ENABLED = "android:smart_actions_enabled";
static final String EXTRA_ACTION_INTENT = "android:screenshot_action_intent";
static final String SCREENSHOT_URI_ID = "android:screenshot_uri_id";
static final String EXTRA_CANCEL_NOTIFICATION = "android:screenshot_cancel_notification";
static final String EXTRA_DISALLOW_ENTER_PIP = "android:screenshot_disallow_enter_pip";
private static final int MESSAGE_CORNER_TIMEOUT = 2;
private static final int SCREENSHOT_CORNER_DEFAULT_TIMEOUT_MILLIS = 6000;
// From WizardManagerHelper.java
private static final String SETTINGS_SECURE_USER_SETUP_COMPLETE = "user_setup_complete";
private final Context mContext;
private final ScreenshotNotificationsController mNotificationsController;
private final ScreenshotSmartActions mScreenshotSmartActions;
private final UiEventLogger mUiEventLogger;
private final WindowManager mWindowManager;
private final WindowManager.LayoutParams mWindowLayoutParams;
private final Display mDisplay;
private final DisplayMetrics mDisplayMetrics;
private final AccessibilityManager mAccessibilityManager;
private final MediaActionSound mCameraSound;
private final ScrollCaptureClient mScrollCaptureClient;
private final DeviceConfigProxy mConfigProxy;
private final PhoneWindow mWindow;
private final View mDecorView;
private final Binder mWindowToken;
private ScreenshotView mScreenshotView;
private Bitmap mScreenBitmap;
private SaveImageInBackgroundTask mSaveInBgTask;
private Animator mScreenshotAnimation;
private Runnable mOnCompleteRunnable;
private boolean mInDarkMode;
private boolean mDirectionLTR;
private boolean mOrientationPortrait;
private final Handler mScreenshotHandler = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case MESSAGE_CORNER_TIMEOUT:
mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_INTERACTION_TIMEOUT);
ScreenshotController.this.dismissScreenshot(false);
break;
default:
break;
}
}
};
@Inject
ScreenshotController(
Context context,
ScreenshotSmartActions screenshotSmartActions,
ScreenshotNotificationsController screenshotNotificationsController,
ScrollCaptureClient scrollCaptureClient,
UiEventLogger uiEventLogger,
DeviceConfigProxy configProxy) {
mScreenshotSmartActions = screenshotSmartActions;
mNotificationsController = screenshotNotificationsController;
mScrollCaptureClient = scrollCaptureClient;
mUiEventLogger = uiEventLogger;
final DisplayManager dm = requireNonNull(context.getSystemService(DisplayManager.class));
mDisplay = dm.getDisplay(DEFAULT_DISPLAY);
mContext = context.createWindowContext(TYPE_SCREENSHOT, null);
mWindowManager = mContext.getSystemService(WindowManager.class);
mAccessibilityManager = AccessibilityManager.getInstance(mContext);
mConfigProxy = configProxy;
Configuration config = mContext.getResources().getConfiguration();
mInDarkMode = config.isNightModeActive();
mDirectionLTR = config.getLayoutDirection() == View.LAYOUT_DIRECTION_LTR;
mOrientationPortrait = config.orientation == ORIENTATION_PORTRAIT;
mWindowToken = new Binder("ScreenshotController");
mScrollCaptureClient.setHostWindowToken(mWindowToken);
// Setup the window that we are going to use
mWindowLayoutParams = new WindowManager.LayoutParams(MATCH_PARENT, MATCH_PARENT, 0, 0,
TYPE_SCREENSHOT,
WindowManager.LayoutParams.FLAG_FULLSCREEN
| WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
| WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
| WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
| WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
| WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM,
PixelFormat.TRANSLUCENT);
mWindowLayoutParams.setTitle("ScreenshotAnimation");
mWindowLayoutParams.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS;
mWindowLayoutParams.token = mWindowToken;
// This is needed to let touches pass through outside the touchable areas
mWindowLayoutParams.privateFlags |= WindowManager.LayoutParams.PRIVATE_FLAG_TRUSTED_OVERLAY;
mWindow = new PhoneWindow(mContext);
mWindow.setWindowManager(mWindowManager, null, null);
mWindow.requestFeature(Window.FEATURE_NO_TITLE);
mWindow.requestFeature(Window.FEATURE_ACTIVITY_TRANSITIONS);
mWindow.setBackgroundDrawableResource(android.R.color.transparent);
mDecorView = mWindow.getDecorView();
reloadAssets();
mDisplayMetrics = new DisplayMetrics();
mDisplay.getRealMetrics(mDisplayMetrics);
// Setup the Camera shutter sound
mCameraSound = new MediaActionSound();
mCameraSound.load(MediaActionSound.SHUTTER_CLICK);
}
void takeScreenshotFullscreen(Consumer<Uri> finisher, Runnable onComplete) {
mOnCompleteRunnable = onComplete;
mDisplay.getRealMetrics(mDisplayMetrics);
takeScreenshotInternal(
finisher,
new Rect(0, 0, mDisplayMetrics.widthPixels, mDisplayMetrics.heightPixels));
}
void handleImageAsScreenshot(Bitmap screenshot, Rect screenshotScreenBounds,
Insets visibleInsets, int taskId, int userId, ComponentName topComponent,
Consumer<Uri> finisher, Runnable onComplete) {
// TODO: use task Id, userId, topComponent for smart handler
mOnCompleteRunnable = onComplete;
if (screenshot == null) {
Log.e(TAG, "Got null bitmap from screenshot message");
mNotificationsController.notifyScreenshotError(
R.string.screenshot_failed_to_capture_text);
finisher.accept(null);
mOnCompleteRunnable.run();
return;
}
if (aspectRatiosMatch(screenshot, visibleInsets, screenshotScreenBounds)) {
saveScreenshot(screenshot, finisher, screenshotScreenBounds, visibleInsets, false);
} else {
saveScreenshot(screenshot, finisher,
new Rect(0, 0, screenshot.getWidth(), screenshot.getHeight()), Insets.NONE,
true);
}
}
/**
* Displays a screenshot selector
*/
@SuppressLint("ClickableViewAccessibility")
void takeScreenshotPartial(final Consumer<Uri> finisher, Runnable onComplete) {
dismissScreenshot(true);
mOnCompleteRunnable = onComplete;
mWindowManager.addView(mScreenshotView, mWindowLayoutParams);
mScreenshotView.takePartialScreenshot(
rect -> takeScreenshotInternal(finisher, rect));
}
/**
* Clears current screenshot
*/
void dismissScreenshot(boolean immediate) {
// If we're already animating out, don't restart the animation
// (but do obey an immediate dismissal)
if (!immediate && mScreenshotView.isDismissing()) {
Log.v(TAG, "Already dismissing, ignoring duplicate command");
return;
}
Log.v(TAG, "Clearing screenshot");
mScreenshotHandler.removeMessages(MESSAGE_CORNER_TIMEOUT);
if (immediate) {
resetScreenshotView();
} else {
mScreenshotView.animateDismissal();
}
}
private void onConfigChanged(Configuration newConfig) {
boolean needsUpdate = false;
// dark mode
if (newConfig.isNightModeActive()) {
// Night mode is active, we're using dark theme
if (!mInDarkMode) {
mInDarkMode = true;
needsUpdate = true;
}
} else {
// Night mode is not active, we're using the light theme
if (mInDarkMode) {
mInDarkMode = false;
needsUpdate = true;
}
}
// RTL configuration
switch (newConfig.getLayoutDirection()) {
case View.LAYOUT_DIRECTION_LTR:
if (!mDirectionLTR) {
mDirectionLTR = true;
needsUpdate = true;
}
break;
case View.LAYOUT_DIRECTION_RTL:
if (mDirectionLTR) {
mDirectionLTR = false;
needsUpdate = true;
}
break;
}
// portrait/landscape orientation
switch (newConfig.orientation) {
case ORIENTATION_PORTRAIT:
if (!mOrientationPortrait) {
mOrientationPortrait = true;
needsUpdate = true;
}
break;
case ORIENTATION_LANDSCAPE:
if (mOrientationPortrait) {
mOrientationPortrait = false;
needsUpdate = true;
}
break;
}
if (needsUpdate) {
reloadAssets();
}
}
/**
* Update assets (called when the dark theme status changes). We only need to update the dismiss
* button and the actions container background, since the buttons are re-inflated on demand.
*/
private void reloadAssets() {
boolean wasAttached = mDecorView.isAttachedToWindow();
if (wasAttached) {
mWindowManager.removeView(mDecorView);
}
// respect the display cutout in landscape (since we'd otherwise overlap) but not portrait
mWindowLayoutParams.setFitInsetsTypes(
mOrientationPortrait ? 0 : WindowInsets.Type.displayCutout());
// ignore system bar insets for the purpose of window layout
mDecorView.setOnApplyWindowInsetsListener((v, insets) -> v.onApplyWindowInsets(
new WindowInsets.Builder(insets)
.setInsets(WindowInsets.Type.all(), Insets.NONE)
.build()));
// Inflate the screenshot layout
mScreenshotView = (ScreenshotView)
LayoutInflater.from(mContext).inflate(R.layout.global_screenshot, null);
mScreenshotView.init(mUiEventLogger, this::resetScreenshotView);
// TODO(159460485): Remove this when focus is handled properly in the system
mScreenshotView.setOnTouchListener((v, event) -> {
if (event.getActionMasked() == MotionEvent.ACTION_OUTSIDE) {
// Once the user touches outside, stop listening for input
setWindowFocusable(false);
}
return false;
});
mScreenshotView.setOnKeyListener((v, keyCode, event) -> {
if (keyCode == KeyEvent.KEYCODE_BACK) {
dismissScreenshot(false);
return true;
}
return false;
});
// view is added to window manager in startAnimation
mWindow.setContentView(mScreenshotView, mWindowLayoutParams);
}
/**
* Takes a screenshot of the current display and shows an animation.
*/
private void takeScreenshotInternal(Consumer<Uri> finisher, Rect crop) {
// copy the input Rect, since SurfaceControl.screenshot can mutate it
Rect screenRect = new Rect(crop);
int width = crop.width();
int height = crop.height();
final IBinder displayToken = SurfaceControl.getInternalDisplayToken();
final SurfaceControl.DisplayCaptureArgs captureArgs =
new SurfaceControl.DisplayCaptureArgs.Builder(displayToken)
.setSourceCrop(crop)
.setSize(width, height)
.build();
final SurfaceControl.ScreenshotHardwareBuffer screenshotBuffer =
SurfaceControl.captureDisplay(captureArgs);
Bitmap screenshot = screenshotBuffer == null ? null : screenshotBuffer.asBitmap();
if (screenshot == null) {
Log.e(TAG, "Screenshot bitmap was null");
mNotificationsController.notifyScreenshotError(
R.string.screenshot_failed_to_capture_text);
finisher.accept(null);
mOnCompleteRunnable.run();
return;
}
saveScreenshot(screenshot, finisher, screenRect, Insets.NONE, true);
}
private void saveScreenshot(Bitmap screenshot, Consumer<Uri> finisher, Rect screenRect,
Insets screenInsets, boolean showFlash) {
if (mAccessibilityManager.isEnabled()) {
AccessibilityEvent event =
new AccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED);
event.setContentDescription(
mContext.getResources().getString(R.string.screenshot_saving_title));
mAccessibilityManager.sendAccessibilityEvent(event);
}
if (mScreenshotView.isAttachedToWindow()) {
// if we didn't already dismiss for another reason
if (!mScreenshotView.isDismissing()) {
mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_REENTERED);
}
mScreenshotView.reset();
}
mScreenBitmap = screenshot;
if (!isUserSetupComplete()) {
// User setup isn't complete, so we don't want to show any UI beyond a toast, as editing
// and sharing shouldn't be exposed to the user.
saveScreenshotAndToast(finisher);
return;
}
// Optimizations
mScreenBitmap.setHasAlpha(false);
mScreenBitmap.prepareToDraw();
onConfigChanged(mContext.getResources().getConfiguration());
// The window is focusable by default
setWindowFocusable(true);
// Start the post-screenshot animation
startAnimation(finisher, screenRect, screenInsets, showFlash);
if (mConfigProxy.getBoolean(DeviceConfig.NAMESPACE_SYSTEMUI,
SystemUiDeviceConfigFlags.SCREENSHOT_SCROLLING_ENABLED, false)) {
mScrollCaptureClient.request(DEFAULT_DISPLAY, (connection) ->
mScreenshotView.showScrollChip(() ->
runScrollCapture(connection,
() -> mScreenshotHandler.post(
() -> dismissScreenshot(false)))));
}
}
private void runScrollCapture(ScrollCaptureClient.Connection connection,
Runnable after) {
new ScrollCaptureController(mContext, connection).run(after);
}
/**
* Save the bitmap but don't show the normal screenshot UI.. just a toast (or notification on
* failure).
*/
private void saveScreenshotAndToast(Consumer<Uri> finisher) {
// Play the shutter sound to notify that we've taken a screenshot
mScreenshotHandler.post(() -> {
mCameraSound.play(MediaActionSound.SHUTTER_CLICK);
});
saveScreenshotInWorkerThread(finisher,
new ScreenshotController.ActionsReadyListener() {
@Override
void onActionsReady(ScreenshotController.SavedImageData imageData) {
finisher.accept(imageData.uri);
if (imageData.uri == null) {
mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_NOT_SAVED);
mNotificationsController.notifyScreenshotError(
R.string.screenshot_failed_to_save_text);
} else {
mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_SAVED);
mScreenshotHandler.post(() -> {
Toast.makeText(mContext, R.string.screenshot_saved_title,
Toast.LENGTH_SHORT).show();
});
}
}
});
}
/**
* Starts the animation after taking the screenshot
*/
private void startAnimation(final Consumer<Uri> finisher, Rect screenRect, Insets screenInsets,
boolean showFlash) {
mScreenshotHandler.removeMessages(MESSAGE_CORNER_TIMEOUT);
mScreenshotHandler.post(() -> {
if (!mScreenshotView.isAttachedToWindow()) {
mWindowManager.addView(mWindow.getDecorView(), mWindowLayoutParams);
}
mScreenshotView.prepareForAnimation(mScreenBitmap, screenInsets);
mScreenshotHandler.post(() -> {
mScreenshotView.getViewTreeObserver().addOnComputeInternalInsetsListener(
mScreenshotView);
mScreenshotAnimation =
mScreenshotView.createScreenshotDropInAnimation(screenRect, showFlash);
saveScreenshotInWorkerThread(finisher,
new ScreenshotController.ActionsReadyListener() {
@Override
void onActionsReady(
ScreenshotController.SavedImageData imageData) {
showUiOnActionsReady(imageData);
}
});
// Play the shutter sound to notify that we've taken a screenshot
mCameraSound.play(MediaActionSound.SHUTTER_CLICK);
mScreenshotAnimation.start();
});
});
}
private void resetScreenshotView() {
if (mScreenshotView.isAttachedToWindow()) {
mWindowManager.removeView(mDecorView);
}
mScreenshotView.reset();
mOnCompleteRunnable.run();
}
/**
* Creates a new worker thread and saves the screenshot to the media store.
*/
private void saveScreenshotInWorkerThread(
Consumer<Uri> finisher,
@Nullable ScreenshotController.ActionsReadyListener actionsReadyListener) {
ScreenshotController.SaveImageInBackgroundData
data = new ScreenshotController.SaveImageInBackgroundData();
data.image = mScreenBitmap;
data.finisher = finisher;
data.mActionsReadyListener = actionsReadyListener;
if (mSaveInBgTask != null) {
// just log success/failure for the pre-existing screenshot
mSaveInBgTask.setActionsReadyListener(
new ScreenshotController.ActionsReadyListener() {
@Override
void onActionsReady(ScreenshotController.SavedImageData imageData) {
logSuccessOnActionsReady(imageData);
}
});
}
mSaveInBgTask = new SaveImageInBackgroundTask(mContext, mScreenshotSmartActions, data);
mSaveInBgTask.execute();
}
/**
* Sets up the action shade and its entrance animation, once we get the screenshot URI.
*/
private void showUiOnActionsReady(ScreenshotController.SavedImageData imageData) {
logSuccessOnActionsReady(imageData);
AccessibilityManager accessibilityManager = (AccessibilityManager)
mContext.getSystemService(Context.ACCESSIBILITY_SERVICE);
long timeoutMs = accessibilityManager.getRecommendedTimeoutMillis(
SCREENSHOT_CORNER_DEFAULT_TIMEOUT_MILLIS,
AccessibilityManager.FLAG_CONTENT_CONTROLS);
mScreenshotHandler.sendMessageDelayed(
mScreenshotHandler.obtainMessage(MESSAGE_CORNER_TIMEOUT),
timeoutMs);
if (imageData.uri != null) {
mScreenshotHandler.post(() -> {
if (mScreenshotAnimation != null && mScreenshotAnimation.isRunning()) {
mScreenshotAnimation.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
mScreenshotView.setChipIntents(imageData);
}
});
} else {
mScreenshotView.setChipIntents(imageData);
}
});
}
}
/**
* Logs success/failure of the screenshot saving task, and shows an error if it failed.
*/
private void logSuccessOnActionsReady(ScreenshotController.SavedImageData imageData) {
if (imageData.uri == null) {
mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_NOT_SAVED);
mNotificationsController.notifyScreenshotError(
R.string.screenshot_failed_to_save_text);
} else {
mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_SAVED);
}
}
private boolean isUserSetupComplete() {
return Settings.Secure.getInt(mContext.getContentResolver(),
SETTINGS_SECURE_USER_SETUP_COMPLETE, 0) == 1;
}
/**
* Updates the window focusability. If the window is already showing, then it updates the
* window immediately, otherwise the layout params will be applied when the window is next
* shown.
*/
private void setWindowFocusable(boolean focusable) {
if (focusable) {
mWindowLayoutParams.flags &= ~WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
} else {
mWindowLayoutParams.flags |= WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
}
if (mDecorView.isAttachedToWindow()) {
mWindowManager.updateViewLayout(mDecorView, mWindowLayoutParams);
}
}
/** Does the aspect ratio of the bitmap with insets removed match the bounds. */
private static boolean aspectRatiosMatch(Bitmap bitmap, Insets bitmapInsets,
Rect screenBounds) {
int insettedWidth = bitmap.getWidth() - bitmapInsets.left - bitmapInsets.right;
int insettedHeight = bitmap.getHeight() - bitmapInsets.top - bitmapInsets.bottom;
if (insettedHeight == 0 || insettedWidth == 0 || bitmap.getWidth() == 0
|| bitmap.getHeight() == 0) {
Log.e(TAG, String.format(
"Provided bitmap and insets create degenerate region: %dx%d %s",
bitmap.getWidth(), bitmap.getHeight(), bitmapInsets));
return false;
}
float insettedBitmapAspect = ((float) insettedWidth) / insettedHeight;
float boundsAspect = ((float) screenBounds.width()) / screenBounds.height();
boolean matchWithinTolerance = Math.abs(insettedBitmapAspect - boundsAspect) < 0.1f;
if (!matchWithinTolerance) {
Log.d(TAG, String.format("aspectRatiosMatch: don't match bitmap: %f, bounds: %f",
insettedBitmapAspect, boundsAspect));
}
return matchWithinTolerance;
}
}