| /* |
| * 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_PORTRAIT; |
| import static android.view.Display.DEFAULT_DISPLAY; |
| import static android.view.WindowManager.LayoutParams.TYPE_SCREENSHOT; |
| |
| import static com.android.systemui.screenshot.LogConfig.DEBUG_ANIM; |
| import static com.android.systemui.screenshot.LogConfig.DEBUG_CALLBACK; |
| import static com.android.systemui.screenshot.LogConfig.DEBUG_DISMISS; |
| import static com.android.systemui.screenshot.LogConfig.DEBUG_INPUT; |
| import static com.android.systemui.screenshot.LogConfig.DEBUG_UI; |
| import static com.android.systemui.screenshot.LogConfig.DEBUG_WINDOW; |
| import static com.android.systemui.screenshot.LogConfig.logTag; |
| import static com.android.systemui.screenshot.ScreenshotEvent.SCREENSHOT_DISMISSED_OTHER; |
| |
| import static java.util.Objects.requireNonNull; |
| |
| import android.animation.Animator; |
| import android.animation.AnimatorListenerAdapter; |
| import android.annotation.MainThread; |
| import android.annotation.Nullable; |
| import android.app.ActivityManager; |
| import android.app.ActivityOptions; |
| import android.app.ExitTransitionCoordinator; |
| import android.app.ExitTransitionCoordinator.ExitTransitionCallbacks; |
| import android.app.ICompatCameraControlCallback; |
| import android.app.Notification; |
| import android.content.BroadcastReceiver; |
| import android.content.ComponentName; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.IntentFilter; |
| import android.content.pm.ActivityInfo; |
| import android.content.res.Configuration; |
| import android.graphics.Bitmap; |
| import android.graphics.Insets; |
| import android.graphics.Rect; |
| import android.hardware.display.DisplayManager; |
| import android.media.AudioAttributes; |
| import android.media.AudioSystem; |
| import android.media.MediaPlayer; |
| import android.net.Uri; |
| import android.os.Bundle; |
| import android.os.IBinder; |
| import android.os.RemoteException; |
| import android.provider.Settings; |
| import android.util.DisplayMetrics; |
| import android.util.Log; |
| import android.util.Pair; |
| import android.view.Display; |
| import android.view.DisplayAddress; |
| import android.view.IRemoteAnimationFinishedCallback; |
| import android.view.IRemoteAnimationRunner; |
| import android.view.KeyEvent; |
| import android.view.LayoutInflater; |
| import android.view.RemoteAnimationAdapter; |
| import android.view.RemoteAnimationTarget; |
| import android.view.ScrollCaptureResponse; |
| import android.view.SurfaceControl; |
| import android.view.View; |
| import android.view.ViewRootImpl; |
| import android.view.ViewTreeObserver; |
| import android.view.WindowInsets; |
| import android.view.WindowManager; |
| import android.view.WindowManagerGlobal; |
| import android.view.accessibility.AccessibilityManager; |
| import android.widget.Toast; |
| import android.window.WindowContext; |
| |
| import androidx.concurrent.futures.CallbackToFutureAdapter; |
| |
| import com.android.internal.app.ChooserActivity; |
| import com.android.internal.logging.UiEventLogger; |
| import com.android.internal.policy.PhoneWindow; |
| import com.android.settingslib.applications.InterestingConfigChanges; |
| import com.android.systemui.R; |
| import com.android.systemui.broadcast.BroadcastSender; |
| import com.android.systemui.clipboardoverlay.ClipboardOverlayController; |
| import com.android.systemui.dagger.qualifiers.Main; |
| import com.android.systemui.screenshot.ScreenshotController.SavedImageData.ActionTransition; |
| import com.android.systemui.screenshot.TakeScreenshotService.RequestCallback; |
| import com.android.systemui.util.Assert; |
| |
| import com.google.common.util.concurrent.ListenableFuture; |
| |
| import java.io.File; |
| import java.util.List; |
| import java.util.concurrent.CancellationException; |
| import java.util.concurrent.ExecutionException; |
| import java.util.concurrent.Executor; |
| import java.util.concurrent.ExecutorService; |
| import java.util.concurrent.Executors; |
| import java.util.concurrent.Future; |
| import java.util.function.Consumer; |
| import java.util.function.Supplier; |
| |
| import javax.inject.Inject; |
| |
| /** |
| * Controls the state and flow for screenshots. |
| */ |
| public class ScreenshotController { |
| private static final String TAG = logTag(ScreenshotController.class); |
| |
| private ScrollCaptureResponse mLastScrollCaptureResponse; |
| private ListenableFuture<ScrollCaptureResponse> mLastScrollCaptureRequest; |
| |
| /** |
| * This is effectively a no-op, but we need something non-null to pass in, in order to |
| * successfully override the pending activity entrance animation. |
| */ |
| static final IRemoteAnimationRunner.Stub SCREENSHOT_REMOTE_RUNNER = |
| new IRemoteAnimationRunner.Stub() { |
| @Override |
| public void onAnimationStart( |
| @WindowManager.TransitionOldType int transit, |
| RemoteAnimationTarget[] apps, |
| RemoteAnimationTarget[] wallpapers, |
| RemoteAnimationTarget[] nonApps, |
| final IRemoteAnimationFinishedCallback finishedCallback) { |
| try { |
| finishedCallback.onAnimationFinished(); |
| } catch (RemoteException e) { |
| Log.e(TAG, "Error finishing screenshot remote animation", e); |
| } |
| } |
| |
| @Override |
| public void onAnimationCancelled(boolean isKeyguardOccluded) { |
| } |
| }; |
| |
| /** |
| * 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; |
| public ScreenshotController.QuickShareActionReadyListener mQuickShareActionsReadyListener; |
| |
| void clearImage() { |
| image = null; |
| } |
| } |
| |
| /** |
| * Structure returned by the SaveImageInBackgroundTask |
| */ |
| static class SavedImageData { |
| public Uri uri; |
| public Supplier<ActionTransition> shareTransition; |
| public Supplier<ActionTransition> editTransition; |
| public Notification.Action deleteAction; |
| public List<Notification.Action> smartActions; |
| public Notification.Action quickShareAction; |
| |
| /** |
| * POD for shared element transition. |
| */ |
| static class ActionTransition { |
| public Bundle bundle; |
| public Notification.Action action; |
| public Runnable onCancelRunnable; |
| } |
| |
| /** |
| * Used to reset the return data on error |
| */ |
| public void reset() { |
| uri = null; |
| shareTransition = null; |
| editTransition = null; |
| deleteAction = null; |
| smartActions = null; |
| quickShareAction = null; |
| } |
| } |
| |
| /** |
| * Structure returned by the QueryQuickShareInBackgroundTask |
| */ |
| static class QuickShareData { |
| public Notification.Action quickShareAction; |
| |
| /** |
| * Used to reset the return data on error |
| */ |
| public void reset() { |
| quickShareAction = null; |
| } |
| } |
| |
| interface ActionsReadyListener { |
| void onActionsReady(ScreenshotController.SavedImageData imageData); |
| } |
| |
| interface QuickShareActionReadyListener { |
| void onActionsReady(ScreenshotController.QuickShareData quickShareData); |
| } |
| |
| interface TransitionDestination { |
| /** |
| * Allows the long screenshot activity to call back with a destination location (the bounds |
| * on screen of the destination for the transitioning view) and a Runnable to be run once |
| * the transition animation is complete. |
| */ |
| void setTransitionDestination(Rect transitionDestination, Runnable onTransitionEnd); |
| } |
| |
| // 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_OVERRIDE_TRANSITION = "android:screenshot_override_transition"; |
| 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"; |
| |
| // From WizardManagerHelper.java |
| private static final String SETTINGS_SECURE_USER_SETUP_COMPLETE = "user_setup_complete"; |
| |
| private static final int SCREENSHOT_CORNER_DEFAULT_TIMEOUT_MILLIS = 6000; |
| |
| private final WindowContext mContext; |
| private final ScreenshotNotificationsController mNotificationsController; |
| private final ScreenshotSmartActions mScreenshotSmartActions; |
| private final UiEventLogger mUiEventLogger; |
| private final ImageExporter mImageExporter; |
| private final Executor mMainExecutor; |
| private final ExecutorService mBgExecutor; |
| private final BroadcastSender mBroadcastSender; |
| |
| private final WindowManager mWindowManager; |
| private final WindowManager.LayoutParams mWindowLayoutParams; |
| private final AccessibilityManager mAccessibilityManager; |
| private final ListenableFuture<MediaPlayer> mCameraSound; |
| private final ScrollCaptureClient mScrollCaptureClient; |
| private final PhoneWindow mWindow; |
| private final DisplayManager mDisplayManager; |
| private final ScrollCaptureController mScrollCaptureController; |
| private final LongScreenshotData mLongScreenshotHolder; |
| private final boolean mIsLowRamDevice; |
| private final TimeoutHandler mScreenshotHandler; |
| |
| private ScreenshotView mScreenshotView; |
| private Bitmap mScreenBitmap; |
| private SaveImageInBackgroundTask mSaveInBgTask; |
| private boolean mScreenshotTakenInPortrait; |
| private boolean mBlockAttach; |
| |
| private Animator mScreenshotAnimation; |
| private RequestCallback mCurrentRequestCallback; |
| private String mPackageName = ""; |
| private BroadcastReceiver mCopyBroadcastReceiver; |
| |
| /** Tracks config changes that require re-creating UI */ |
| private final InterestingConfigChanges mConfigChanges = new InterestingConfigChanges( |
| ActivityInfo.CONFIG_ORIENTATION |
| | ActivityInfo.CONFIG_LAYOUT_DIRECTION |
| | ActivityInfo.CONFIG_LOCALE |
| | ActivityInfo.CONFIG_UI_MODE |
| | ActivityInfo.CONFIG_SCREEN_LAYOUT |
| | ActivityInfo.CONFIG_ASSETS_PATHS); |
| |
| @Inject |
| ScreenshotController( |
| Context context, |
| ScreenshotSmartActions screenshotSmartActions, |
| ScreenshotNotificationsController screenshotNotificationsController, |
| ScrollCaptureClient scrollCaptureClient, |
| UiEventLogger uiEventLogger, |
| ImageExporter imageExporter, |
| @Main Executor mainExecutor, |
| ScrollCaptureController scrollCaptureController, |
| LongScreenshotData longScreenshotHolder, |
| ActivityManager activityManager, |
| TimeoutHandler timeoutHandler, |
| BroadcastSender broadcastSender) { |
| mScreenshotSmartActions = screenshotSmartActions; |
| mNotificationsController = screenshotNotificationsController; |
| mScrollCaptureClient = scrollCaptureClient; |
| mUiEventLogger = uiEventLogger; |
| mImageExporter = imageExporter; |
| mMainExecutor = mainExecutor; |
| mScrollCaptureController = scrollCaptureController; |
| mLongScreenshotHolder = longScreenshotHolder; |
| mIsLowRamDevice = activityManager.isLowRamDevice(); |
| mBgExecutor = Executors.newSingleThreadExecutor(); |
| mBroadcastSender = broadcastSender; |
| |
| mScreenshotHandler = timeoutHandler; |
| mScreenshotHandler.setDefaultTimeoutMillis(SCREENSHOT_CORNER_DEFAULT_TIMEOUT_MILLIS); |
| mScreenshotHandler.setOnTimeoutRunnable(() -> { |
| if (DEBUG_UI) { |
| Log.d(TAG, "Corner timeout hit"); |
| } |
| mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_INTERACTION_TIMEOUT, 0, |
| mPackageName); |
| ScreenshotController.this.dismissScreenshot(false); |
| }); |
| |
| mDisplayManager = requireNonNull(context.getSystemService(DisplayManager.class)); |
| final Context displayContext = context.createDisplayContext(getDefaultDisplay()); |
| mContext = (WindowContext) displayContext.createWindowContext(TYPE_SCREENSHOT, null); |
| mWindowManager = mContext.getSystemService(WindowManager.class); |
| |
| mAccessibilityManager = AccessibilityManager.getInstance(mContext); |
| |
| // Setup the window that we are going to use |
| mWindowLayoutParams = FloatingWindowUtil.getFloatingWindowParams(); |
| mWindowLayoutParams.setTitle("ScreenshotAnimation"); |
| |
| mWindow = FloatingWindowUtil.getFloatingWindow(mContext); |
| mWindow.setWindowManager(mWindowManager, null, null); |
| |
| mConfigChanges.applyNewConfig(context.getResources()); |
| reloadAssets(); |
| |
| // Setup the Camera shutter sound |
| mCameraSound = loadCameraSound(); |
| |
| mCopyBroadcastReceiver = new BroadcastReceiver() { |
| @Override |
| public void onReceive(Context context, Intent intent) { |
| if (ClipboardOverlayController.COPY_OVERLAY_ACTION.equals(intent.getAction())) { |
| mUiEventLogger.log(SCREENSHOT_DISMISSED_OTHER); |
| dismissScreenshot(false); |
| } |
| } |
| }; |
| mContext.registerReceiver(mCopyBroadcastReceiver, new IntentFilter( |
| ClipboardOverlayController.COPY_OVERLAY_ACTION), |
| ClipboardOverlayController.SELF_PERMISSION, null, Context.RECEIVER_NOT_EXPORTED); |
| } |
| |
| @MainThread |
| void takeScreenshotFullscreen(ComponentName topComponent, Consumer<Uri> finisher, |
| RequestCallback requestCallback) { |
| Assert.isMainThread(); |
| mCurrentRequestCallback = requestCallback; |
| DisplayMetrics displayMetrics = new DisplayMetrics(); |
| getDefaultDisplay().getRealMetrics(displayMetrics); |
| takeScreenshotInternal( |
| topComponent, finisher, |
| new Rect(0, 0, displayMetrics.widthPixels, displayMetrics.heightPixels)); |
| } |
| |
| @MainThread |
| void handleImageAsScreenshot(Bitmap screenshot, Rect screenshotScreenBounds, |
| Insets visibleInsets, int taskId, int userId, ComponentName topComponent, |
| Consumer<Uri> finisher, RequestCallback requestCallback) { |
| // TODO: use task Id, userId, topComponent for smart handler |
| Assert.isMainThread(); |
| if (screenshot == null) { |
| Log.e(TAG, "Got null bitmap from screenshot message"); |
| mNotificationsController.notifyScreenshotError( |
| R.string.screenshot_failed_to_capture_text); |
| requestCallback.reportError(); |
| return; |
| } |
| |
| boolean showFlash = false; |
| if (!aspectRatiosMatch(screenshot, visibleInsets, screenshotScreenBounds)) { |
| showFlash = true; |
| visibleInsets = Insets.NONE; |
| screenshotScreenBounds.set(0, 0, screenshot.getWidth(), screenshot.getHeight()); |
| } |
| mCurrentRequestCallback = requestCallback; |
| saveScreenshot(screenshot, finisher, screenshotScreenBounds, visibleInsets, topComponent, |
| showFlash); |
| } |
| |
| /** |
| * Displays a screenshot selector |
| */ |
| @MainThread |
| void takeScreenshotPartial(ComponentName topComponent, |
| final Consumer<Uri> finisher, RequestCallback requestCallback) { |
| Assert.isMainThread(); |
| mScreenshotView.reset(); |
| mCurrentRequestCallback = requestCallback; |
| |
| attachWindow(); |
| mWindow.setContentView(mScreenshotView); |
| mScreenshotView.requestApplyInsets(); |
| |
| mScreenshotView.takePartialScreenshot( |
| rect -> takeScreenshotInternal(topComponent, finisher, rect)); |
| } |
| |
| /** |
| * Clears current screenshot |
| */ |
| void dismissScreenshot(boolean immediate) { |
| if (DEBUG_DISMISS) { |
| Log.d(TAG, "dismissScreenshot(immediate=" + immediate + ")"); |
| } |
| // If we're already animating out, don't restart the animation |
| // (but do obey an immediate dismissal) |
| if (!immediate && mScreenshotView.isDismissing()) { |
| if (DEBUG_DISMISS) { |
| Log.v(TAG, "Already dismissing, ignoring duplicate command"); |
| } |
| return; |
| } |
| mScreenshotHandler.cancelTimeout(); |
| if (immediate) { |
| finishDismiss(); |
| } else { |
| mScreenshotView.animateDismissal(); |
| } |
| } |
| |
| boolean isPendingSharedTransition() { |
| return mScreenshotView.isPendingSharedTransition(); |
| } |
| |
| // Any cleanup needed when the service is being destroyed. |
| void onDestroy() { |
| removeWindow(); |
| releaseMediaPlayer(); |
| releaseContext(); |
| mBgExecutor.shutdownNow(); |
| } |
| |
| /** |
| * Release the constructed window context. |
| */ |
| private void releaseContext() { |
| mContext.unregisterReceiver(mCopyBroadcastReceiver); |
| mContext.release(); |
| } |
| |
| private void releaseMediaPlayer() { |
| // Note that this may block if the sound is still being loaded (very unlikely) but we can't |
| // reliably release in the background because the service is being destroyed. |
| try { |
| MediaPlayer player = mCameraSound.get(); |
| if (player != null) { |
| player.release(); |
| } |
| } catch (InterruptedException | ExecutionException e) { |
| } |
| } |
| |
| /** |
| * Update resources on configuration change. Reinflate for theme/color changes. |
| */ |
| private void reloadAssets() { |
| if (DEBUG_UI) { |
| Log.d(TAG, "reloadAssets()"); |
| } |
| |
| // Inflate the screenshot layout |
| mScreenshotView = (ScreenshotView) |
| LayoutInflater.from(mContext).inflate(R.layout.screenshot, null); |
| mScreenshotView.init(mUiEventLogger, new ScreenshotView.ScreenshotViewCallback() { |
| @Override |
| public void onUserInteraction() { |
| if (DEBUG_INPUT) { |
| Log.d(TAG, "onUserInteraction"); |
| } |
| mScreenshotHandler.resetTimeout(); |
| } |
| |
| @Override |
| public void onDismiss() { |
| finishDismiss(); |
| } |
| |
| @Override |
| public void onTouchOutside() { |
| // TODO(159460485): Remove this when focus is handled properly in the system |
| setWindowFocusable(false); |
| } |
| }); |
| mScreenshotView.setDefaultTimeoutMillis(mScreenshotHandler.getDefaultTimeoutMillis()); |
| |
| mScreenshotView.setOnKeyListener((v, keyCode, event) -> { |
| if (keyCode == KeyEvent.KEYCODE_BACK) { |
| if (DEBUG_INPUT) { |
| Log.d(TAG, "onKeyEvent: KeyEvent.KEYCODE_BACK"); |
| } |
| dismissScreenshot(false); |
| return true; |
| } |
| return false; |
| }); |
| |
| if (DEBUG_WINDOW) { |
| Log.d(TAG, "adding OnComputeInternalInsetsListener"); |
| } |
| mScreenshotView.getViewTreeObserver().addOnComputeInternalInsetsListener(mScreenshotView); |
| } |
| |
| /** |
| * Takes a screenshot of the current display and shows an animation. |
| */ |
| private void takeScreenshotInternal(ComponentName topComponent, Consumer<Uri> finisher, |
| Rect crop) { |
| mScreenshotTakenInPortrait = |
| mContext.getResources().getConfiguration().orientation == ORIENTATION_PORTRAIT; |
| |
| // copy the input Rect, since SurfaceControl.screenshot can mutate it |
| Rect screenRect = new Rect(crop); |
| Bitmap screenshot = captureScreenshot(crop); |
| |
| if (screenshot == null) { |
| Log.e(TAG, "takeScreenshotInternal: Screenshot bitmap was null"); |
| mNotificationsController.notifyScreenshotError( |
| R.string.screenshot_failed_to_capture_text); |
| if (mCurrentRequestCallback != null) { |
| mCurrentRequestCallback.reportError(); |
| } |
| return; |
| } |
| |
| saveScreenshot(screenshot, finisher, screenRect, Insets.NONE, topComponent, true); |
| |
| mBroadcastSender.sendBroadcast(new Intent(ClipboardOverlayController.SCREENSHOT_ACTION), |
| ClipboardOverlayController.SELF_PERMISSION); |
| } |
| |
| private Bitmap captureScreenshot(Rect crop) { |
| int width = crop.width(); |
| int height = crop.height(); |
| Bitmap screenshot = null; |
| final Display display = getDefaultDisplay(); |
| final DisplayAddress address = display.getAddress(); |
| if (!(address instanceof DisplayAddress.Physical)) { |
| Log.e(TAG, "Skipping Screenshot - Default display does not have a physical address: " |
| + display); |
| } else { |
| final DisplayAddress.Physical physicalAddress = (DisplayAddress.Physical) address; |
| |
| final IBinder displayToken = SurfaceControl.getPhysicalDisplayToken( |
| physicalAddress.getPhysicalDisplayId()); |
| final SurfaceControl.DisplayCaptureArgs captureArgs = |
| new SurfaceControl.DisplayCaptureArgs.Builder(displayToken) |
| .setSourceCrop(crop) |
| .setSize(width, height) |
| .build(); |
| final SurfaceControl.ScreenshotHardwareBuffer screenshotBuffer = |
| SurfaceControl.captureDisplay(captureArgs); |
| screenshot = screenshotBuffer == null ? null : screenshotBuffer.asBitmap(); |
| } |
| return screenshot; |
| } |
| |
| private void saveScreenshot(Bitmap screenshot, Consumer<Uri> finisher, Rect screenRect, |
| Insets screenInsets, ComponentName topComponent, boolean showFlash) { |
| withWindowAttached(() -> |
| mScreenshotView.announceForAccessibility( |
| mContext.getResources().getString(R.string.screenshot_saving_title))); |
| |
| if (mScreenshotView.isAttachedToWindow()) { |
| // if we didn't already dismiss for another reason |
| if (!mScreenshotView.isDismissing()) { |
| mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_REENTERED, 0, mPackageName); |
| } |
| if (DEBUG_WINDOW) { |
| Log.d(TAG, "saveScreenshot: screenshotView is already attached, resetting. " |
| + "(dismissing=" + mScreenshotView.isDismissing() + ")"); |
| } |
| mScreenshotView.reset(); |
| } |
| mPackageName = topComponent == null ? "" : topComponent.getPackageName(); |
| mScreenshotView.setPackageName(mPackageName); |
| |
| mScreenshotView.updateOrientation( |
| mWindowManager.getCurrentWindowMetrics().getWindowInsets()); |
| |
| mScreenBitmap = screenshot; |
| |
| if (!isUserSetupComplete()) { |
| Log.w(TAG, "User setup not complete, displaying toast only"); |
| // 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(); |
| |
| saveScreenshotInWorkerThread(finisher, this::showUiOnActionsReady, |
| this::showUiOnQuickShareActionReady); |
| |
| // The window is focusable by default |
| setWindowFocusable(true); |
| |
| // Wait until this window is attached to request because it is |
| // the reference used to locate the target window (below). |
| withWindowAttached(() -> { |
| requestScrollCapture(); |
| mWindow.peekDecorView().getViewRootImpl().setActivityConfigCallback( |
| new ViewRootImpl.ActivityConfigCallback() { |
| @Override |
| public void onConfigurationChanged(Configuration overrideConfig, |
| int newDisplayId) { |
| if (mConfigChanges.applyNewConfig(mContext.getResources())) { |
| // Hide the scroll chip until we know it's available in this |
| // orientation |
| mScreenshotView.hideScrollChip(); |
| // Delay scroll capture eval a bit to allow the underlying activity |
| // to set up in the new orientation. |
| mScreenshotHandler.postDelayed( |
| ScreenshotController.this::requestScrollCapture, 150); |
| mScreenshotView.updateInsets( |
| mWindowManager.getCurrentWindowMetrics() |
| .getWindowInsets()); |
| // Screenshot animation calculations won't be valid anymore, |
| // so just end |
| if (mScreenshotAnimation != null |
| && mScreenshotAnimation.isRunning()) { |
| mScreenshotAnimation.end(); |
| } |
| } |
| } |
| |
| @Override |
| public void requestCompatCameraControl(boolean showControl, |
| boolean transformationApplied, |
| ICompatCameraControlCallback callback) { |
| Log.w(TAG, "Unexpected requestCompatCameraControl callback"); |
| } |
| }); |
| }); |
| |
| attachWindow(); |
| mScreenshotView.getViewTreeObserver().addOnPreDrawListener( |
| new ViewTreeObserver.OnPreDrawListener() { |
| @Override |
| public boolean onPreDraw() { |
| if (DEBUG_WINDOW) { |
| Log.d(TAG, "onPreDraw: startAnimation"); |
| } |
| mScreenshotView.getViewTreeObserver().removeOnPreDrawListener(this); |
| startAnimation(screenRect, showFlash); |
| return true; |
| } |
| }); |
| mScreenshotView.setScreenshot(mScreenBitmap, screenInsets); |
| if (DEBUG_WINDOW) { |
| Log.d(TAG, "setContentView: " + mScreenshotView); |
| } |
| setContentView(mScreenshotView); |
| // ignore system bar insets for the purpose of window layout |
| mWindow.getDecorView().setOnApplyWindowInsetsListener( |
| (v, insets) -> WindowInsets.CONSUMED); |
| mScreenshotHandler.cancelTimeout(); // restarted after animation |
| } |
| |
| private void requestScrollCapture() { |
| if (!allowLongScreenshots()) { |
| Log.d(TAG, "Long screenshots not supported on this device"); |
| return; |
| } |
| mScrollCaptureClient.setHostWindowToken(mWindow.getDecorView().getWindowToken()); |
| if (mLastScrollCaptureRequest != null) { |
| mLastScrollCaptureRequest.cancel(true); |
| } |
| final ListenableFuture<ScrollCaptureResponse> future = |
| mScrollCaptureClient.request(DEFAULT_DISPLAY); |
| mLastScrollCaptureRequest = future; |
| mLastScrollCaptureRequest.addListener(() -> |
| onScrollCaptureResponseReady(future), mMainExecutor); |
| } |
| |
| private void onScrollCaptureResponseReady(Future<ScrollCaptureResponse> responseFuture) { |
| try { |
| if (mLastScrollCaptureResponse != null) { |
| mLastScrollCaptureResponse.close(); |
| mLastScrollCaptureResponse = null; |
| } |
| if (responseFuture.isCancelled()) { |
| return; |
| } |
| mLastScrollCaptureResponse = responseFuture.get(); |
| if (!mLastScrollCaptureResponse.isConnected()) { |
| // No connection means that the target window wasn't found |
| // or that it cannot support scroll capture. |
| Log.d(TAG, "ScrollCapture: " + mLastScrollCaptureResponse.getDescription() + " [" |
| + mLastScrollCaptureResponse.getWindowTitle() + "]"); |
| return; |
| } |
| Log.d(TAG, "ScrollCapture: connected to window [" |
| + mLastScrollCaptureResponse.getWindowTitle() + "]"); |
| |
| final ScrollCaptureResponse response = mLastScrollCaptureResponse; |
| mScreenshotView.showScrollChip(response.getPackageName(), /* onClick */ () -> { |
| DisplayMetrics displayMetrics = new DisplayMetrics(); |
| getDefaultDisplay().getRealMetrics(displayMetrics); |
| Bitmap newScreenshot = captureScreenshot( |
| new Rect(0, 0, displayMetrics.widthPixels, displayMetrics.heightPixels)); |
| |
| mScreenshotView.prepareScrollingTransition(response, mScreenBitmap, newScreenshot, |
| mScreenshotTakenInPortrait); |
| // delay starting scroll capture to make sure the scrim is up before the app moves |
| mScreenshotView.post(() -> runBatchScrollCapture(response)); |
| }); |
| } catch (InterruptedException | ExecutionException e) { |
| Log.e(TAG, "requestScrollCapture failed", e); |
| } |
| } |
| |
| ListenableFuture<ScrollCaptureController.LongScreenshot> mLongScreenshotFuture; |
| |
| private void runBatchScrollCapture(ScrollCaptureResponse response) { |
| // Clear the reference to prevent close() in dismissScreenshot |
| mLastScrollCaptureResponse = null; |
| |
| if (mLongScreenshotFuture != null) { |
| mLongScreenshotFuture.cancel(true); |
| } |
| mLongScreenshotFuture = mScrollCaptureController.run(response); |
| mLongScreenshotFuture.addListener(() -> { |
| ScrollCaptureController.LongScreenshot longScreenshot; |
| try { |
| longScreenshot = mLongScreenshotFuture.get(); |
| } catch (CancellationException e) { |
| Log.e(TAG, "Long screenshot cancelled"); |
| return; |
| } catch (InterruptedException | ExecutionException e) { |
| Log.e(TAG, "Exception", e); |
| mScreenshotView.restoreNonScrollingUi(); |
| return; |
| } |
| |
| if (longScreenshot.getHeight() == 0) { |
| mScreenshotView.restoreNonScrollingUi(); |
| return; |
| } |
| |
| mLongScreenshotHolder.setLongScreenshot(longScreenshot); |
| mLongScreenshotHolder.setTransitionDestinationCallback( |
| (transitionDestination, onTransitionEnd) -> |
| mScreenshotView.startLongScreenshotTransition( |
| transitionDestination, onTransitionEnd, |
| longScreenshot)); |
| |
| final Intent intent = new Intent(mContext, LongScreenshotActivity.class); |
| intent.setFlags( |
| Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); |
| |
| mContext.startActivity(intent, |
| ActivityOptions.makeCustomAnimation(mContext, 0, 0).toBundle()); |
| RemoteAnimationAdapter runner = new RemoteAnimationAdapter( |
| SCREENSHOT_REMOTE_RUNNER, 0, 0); |
| try { |
| WindowManagerGlobal.getWindowManagerService() |
| .overridePendingAppTransitionRemote(runner, DEFAULT_DISPLAY); |
| } catch (Exception e) { |
| Log.e(TAG, "Error overriding screenshot app transition", e); |
| } |
| }, mMainExecutor); |
| } |
| |
| private void withWindowAttached(Runnable action) { |
| View decorView = mWindow.getDecorView(); |
| if (decorView.isAttachedToWindow()) { |
| action.run(); |
| } else { |
| decorView.getViewTreeObserver().addOnWindowAttachListener( |
| new ViewTreeObserver.OnWindowAttachListener() { |
| @Override |
| public void onWindowAttached() { |
| mBlockAttach = false; |
| decorView.getViewTreeObserver().removeOnWindowAttachListener(this); |
| action.run(); |
| } |
| |
| @Override |
| public void onWindowDetached() { |
| } |
| }); |
| |
| } |
| } |
| |
| private void setContentView(View contentView) { |
| mWindow.setContentView(contentView); |
| } |
| |
| @MainThread |
| private void attachWindow() { |
| View decorView = mWindow.getDecorView(); |
| if (decorView.isAttachedToWindow() || mBlockAttach) { |
| return; |
| } |
| if (DEBUG_WINDOW) { |
| Log.d(TAG, "attachWindow"); |
| } |
| mBlockAttach = true; |
| mWindowManager.addView(decorView, mWindowLayoutParams); |
| decorView.requestApplyInsets(); |
| } |
| |
| void removeWindow() { |
| final View decorView = mWindow.peekDecorView(); |
| if (decorView != null && decorView.isAttachedToWindow()) { |
| if (DEBUG_WINDOW) { |
| Log.d(TAG, "Removing screenshot window"); |
| } |
| mWindowManager.removeViewImmediate(decorView); |
| } |
| // Ensure that we remove the input monitor |
| if (mScreenshotView != null) { |
| mScreenshotView.stopInputListening(); |
| } |
| } |
| |
| private ListenableFuture<MediaPlayer> loadCameraSound() { |
| // The media player creation is slow and needs on the background thread. |
| return CallbackToFutureAdapter.getFuture((completer) -> { |
| mBgExecutor.execute(() -> { |
| MediaPlayer player = MediaPlayer.create(mContext, |
| Uri.fromFile(new File(mContext.getResources().getString( |
| com.android.internal.R.string.config_cameraShutterSound))), null, |
| new AudioAttributes.Builder() |
| .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) |
| .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) |
| .build(), AudioSystem.newAudioSessionId()); |
| completer.set(player); |
| }); |
| return "ScreenshotController#loadCameraSound"; |
| }); |
| } |
| |
| private void playCameraSound() { |
| mCameraSound.addListener(() -> { |
| try { |
| MediaPlayer player = mCameraSound.get(); |
| if (player != null) { |
| player.start(); |
| } |
| } catch (InterruptedException | ExecutionException e) { |
| } |
| }, mBgExecutor); |
| } |
| |
| /** |
| * 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 |
| playCameraSound(); |
| |
| saveScreenshotInWorkerThread( |
| /* onComplete */ finisher, |
| /* actionsReadyListener */ imageData -> { |
| if (DEBUG_CALLBACK) { |
| Log.d(TAG, "returning URI to finisher (Consumer<URI>): " + imageData.uri); |
| } |
| finisher.accept(imageData.uri); |
| if (imageData.uri == null) { |
| mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_NOT_SAVED, 0, mPackageName); |
| mNotificationsController.notifyScreenshotError( |
| R.string.screenshot_failed_to_save_text); |
| } else { |
| mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_SAVED, 0, mPackageName); |
| mScreenshotHandler.post(() -> Toast.makeText(mContext, |
| R.string.screenshot_saved_title, Toast.LENGTH_SHORT).show()); |
| } |
| }, |
| null); |
| } |
| |
| /** |
| * Starts the animation after taking the screenshot |
| */ |
| private void startAnimation(Rect screenRect, boolean showFlash) { |
| if (mScreenshotAnimation != null && mScreenshotAnimation.isRunning()) { |
| mScreenshotAnimation.cancel(); |
| } |
| |
| mScreenshotAnimation = |
| mScreenshotView.createScreenshotDropInAnimation(screenRect, showFlash); |
| |
| // Play the shutter sound to notify that we've taken a screenshot |
| playCameraSound(); |
| |
| if (DEBUG_ANIM) { |
| Log.d(TAG, "starting post-screenshot animation"); |
| } |
| mScreenshotAnimation.start(); |
| } |
| |
| /** Reset screenshot view and then call onCompleteRunnable */ |
| private void finishDismiss() { |
| if (DEBUG_DISMISS) { |
| Log.d(TAG, "finishDismiss"); |
| } |
| if (mLastScrollCaptureRequest != null) { |
| mLastScrollCaptureRequest.cancel(true); |
| mLastScrollCaptureRequest = null; |
| } |
| if (mLastScrollCaptureResponse != null) { |
| mLastScrollCaptureResponse.close(); |
| mLastScrollCaptureResponse = null; |
| } |
| if (mLongScreenshotFuture != null) { |
| mLongScreenshotFuture.cancel(true); |
| } |
| if (mCurrentRequestCallback != null) { |
| mCurrentRequestCallback.onFinish(); |
| mCurrentRequestCallback = null; |
| } |
| mScreenshotView.reset(); |
| removeWindow(); |
| mScreenshotHandler.cancelTimeout(); |
| } |
| |
| /** |
| * Creates a new worker thread and saves the screenshot to the media store. |
| */ |
| private void saveScreenshotInWorkerThread(Consumer<Uri> finisher, |
| @Nullable ScreenshotController.ActionsReadyListener actionsReadyListener, |
| @Nullable ScreenshotController.QuickShareActionReadyListener |
| quickShareActionsReadyListener) { |
| ScreenshotController.SaveImageInBackgroundData |
| data = new ScreenshotController.SaveImageInBackgroundData(); |
| data.image = mScreenBitmap; |
| data.finisher = finisher; |
| data.mActionsReadyListener = actionsReadyListener; |
| data.mQuickShareActionsReadyListener = quickShareActionsReadyListener; |
| |
| if (mSaveInBgTask != null) { |
| // just log success/failure for the pre-existing screenshot |
| mSaveInBgTask.setActionsReadyListener(this::logSuccessOnActionsReady); |
| } |
| |
| mSaveInBgTask = new SaveImageInBackgroundTask(mContext, mImageExporter, |
| mScreenshotSmartActions, data, getActionTransitionSupplier()); |
| 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); |
| if (DEBUG_UI) { |
| Log.d(TAG, "Showing UI actions"); |
| } |
| |
| mScreenshotHandler.resetTimeout(); |
| |
| 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); |
| } |
| }); |
| } |
| } |
| |
| /** |
| * Sets up the action shade and its entrance animation, once we get the Quick Share action data. |
| */ |
| private void showUiOnQuickShareActionReady(ScreenshotController.QuickShareData quickShareData) { |
| if (DEBUG_UI) { |
| Log.d(TAG, "Showing UI for Quick Share action"); |
| } |
| if (quickShareData.quickShareAction != null) { |
| mScreenshotHandler.post(() -> { |
| if (mScreenshotAnimation != null && mScreenshotAnimation.isRunning()) { |
| mScreenshotAnimation.addListener(new AnimatorListenerAdapter() { |
| @Override |
| public void onAnimationEnd(Animator animation) { |
| super.onAnimationEnd(animation); |
| mScreenshotView.addQuickShareChip(quickShareData.quickShareAction); |
| } |
| }); |
| } else { |
| mScreenshotView.addQuickShareChip(quickShareData.quickShareAction); |
| } |
| }); |
| } |
| } |
| |
| /** |
| * Supplies the necessary bits for the shared element transition to share sheet. |
| * Note that once supplied, the action intent to share must be sent immediately after. |
| */ |
| private Supplier<ActionTransition> getActionTransitionSupplier() { |
| return () -> { |
| Pair<ActivityOptions, ExitTransitionCoordinator> transition = |
| ActivityOptions.startSharedElementAnimation( |
| mWindow, new ScreenshotExitTransitionCallbacksSupplier(true).get(), |
| null, Pair.create(mScreenshotView.getScreenshotPreview(), |
| ChooserActivity.FIRST_IMAGE_PREVIEW_TRANSITION_NAME)); |
| transition.second.startExit(); |
| |
| ActionTransition supply = new ActionTransition(); |
| supply.bundle = transition.first.toBundle(); |
| supply.onCancelRunnable = () -> ActivityOptions.stopSharedElementAnimation(mWindow); |
| return supply; |
| }; |
| } |
| |
| /** |
| * 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, 0, mPackageName); |
| mNotificationsController.notifyScreenshotError( |
| R.string.screenshot_failed_to_save_text); |
| } else { |
| mUiEventLogger.log(ScreenshotEvent.SCREENSHOT_SAVED, 0, mPackageName); |
| } |
| } |
| |
| 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 (DEBUG_WINDOW) { |
| Log.d(TAG, "setWindowFocusable: " + focusable); |
| } |
| int flags = mWindowLayoutParams.flags; |
| if (focusable) { |
| mWindowLayoutParams.flags &= ~WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; |
| } else { |
| mWindowLayoutParams.flags |= WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; |
| } |
| if (mWindowLayoutParams.flags == flags) { |
| if (DEBUG_WINDOW) { |
| Log.d(TAG, "setWindowFocusable: skipping, already " + focusable); |
| } |
| return; |
| } |
| final View decorView = mWindow.peekDecorView(); |
| if (decorView != null && decorView.isAttachedToWindow()) { |
| mWindowManager.updateViewLayout(decorView, mWindowLayoutParams); |
| } |
| } |
| |
| private Display getDefaultDisplay() { |
| return mDisplayManager.getDisplay(DEFAULT_DISPLAY); |
| } |
| |
| private boolean allowLongScreenshots() { |
| return !mIsLowRamDevice; |
| } |
| |
| /** 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) { |
| if (DEBUG_UI) { |
| Log.e(TAG, "Provided bitmap and insets create degenerate region: " |
| + bitmap.getWidth() + "x" + 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 (DEBUG_UI) { |
| Log.d(TAG, "aspectRatiosMatch: don't match bitmap: " + insettedBitmapAspect |
| + ", bounds: " + boundsAspect); |
| } |
| return matchWithinTolerance; |
| } |
| |
| private class ScreenshotExitTransitionCallbacksSupplier implements |
| Supplier<ExitTransitionCallbacks> { |
| final boolean mDismissOnHideSharedElements; |
| |
| ScreenshotExitTransitionCallbacksSupplier(boolean dismissOnHideSharedElements) { |
| mDismissOnHideSharedElements = dismissOnHideSharedElements; |
| } |
| |
| @Override |
| public ExitTransitionCallbacks get() { |
| return new ExitTransitionCallbacks() { |
| @Override |
| public boolean isReturnTransitionAllowed() { |
| return false; |
| } |
| |
| @Override |
| public void hideSharedElements() { |
| if (mDismissOnHideSharedElements) { |
| finishDismiss(); |
| } |
| } |
| |
| @Override |
| public void onFinish() { |
| } |
| }; |
| } |
| } |
| } |