diff options
14 files changed, 945 insertions, 32 deletions
diff --git a/packages/SystemUI/res/drawable/ic_screenshot_scroll.xml b/packages/SystemUI/res/drawable/ic_screenshot_scroll.xml new file mode 100644 index 000000000000..c260ba9bf421 --- /dev/null +++ b/packages/SystemUI/res/drawable/ic_screenshot_scroll.xml @@ -0,0 +1,25 @@ +<!-- + ~ 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. + --> +<!-- ic_unfold_more_24px.xml --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#FFFFFFFF" + android:pathData="M12,5.83L15.17,9l1.41,-1.41L12,3 7.41,7.59 8.83,9 12,5.83zM12,18.17L8.83,15l-1.41,1.41L12,21l4.59,-4.59L15.17,15 12,18.17z"/> +</vector> diff --git a/packages/SystemUI/res/layout-land/global_screenshot_preview.xml b/packages/SystemUI/res/layout-land/global_screenshot_preview.xml index 040303a9b963..71b414fd419e 100644 --- a/packages/SystemUI/res/layout-land/global_screenshot_preview.xml +++ b/packages/SystemUI/res/layout-land/global_screenshot_preview.xml @@ -28,6 +28,6 @@ android:visibility="gone" android:background="@drawable/screenshot_rounded_corners" android:adjustViewBounds="true" - android:contentDescription="@string/screenshot_edit" + android:contentDescription="@string/screenshot_edit_label" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent"/>
\ No newline at end of file diff --git a/packages/SystemUI/res/layout/global_screenshot.xml b/packages/SystemUI/res/layout/global_screenshot.xml index 1b5f9c1ced8f..6c20c1e95c6d 100644 --- a/packages/SystemUI/res/layout/global_screenshot.xml +++ b/packages/SystemUI/res/layout/global_screenshot.xml @@ -46,7 +46,7 @@ android:layout_height="@dimen/screenshot_dismiss_button_tappable_size" android:elevation="7dp" android:visibility="gone" - android:contentDescription="@string/screenshot_dismiss_ui_description"> + android:contentDescription="@string/screenshot_dismiss_description"> <ImageView android:id="@+id/global_screenshot_dismiss_image" android:layout_width="match_parent" diff --git a/packages/SystemUI/res/layout/global_screenshot_preview.xml b/packages/SystemUI/res/layout/global_screenshot_preview.xml index c745854b1c6c..5262407ffef9 100644 --- a/packages/SystemUI/res/layout/global_screenshot_preview.xml +++ b/packages/SystemUI/res/layout/global_screenshot_preview.xml @@ -28,6 +28,6 @@ android:visibility="gone" android:background="@drawable/screenshot_rounded_corners" android:adjustViewBounds="true" - android:contentDescription="@string/screenshot_edit" + android:contentDescription="@string/screenshot_edit_label" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent"/>
\ No newline at end of file diff --git a/packages/SystemUI/res/layout/global_screenshot_static.xml b/packages/SystemUI/res/layout/global_screenshot_static.xml index 26edf3afc0c5..096ec7ddd004 100644 --- a/packages/SystemUI/res/layout/global_screenshot_static.xml +++ b/packages/SystemUI/res/layout/global_screenshot_static.xml @@ -56,6 +56,9 @@ android:id="@+id/screenshot_share_chip"/> <include layout="@layout/global_screenshot_action_chip" android:id="@+id/screenshot_edit_chip"/> + <include layout="@layout/global_screenshot_action_chip" + android:id="@+id/screenshot_scroll_chip" + android:visibility="gone" /> </LinearLayout> </HorizontalScrollView> <include layout="@layout/global_screenshot_preview"/> diff --git a/packages/SystemUI/res/values/strings.xml b/packages/SystemUI/res/values/strings.xml index d5c98233b952..8e2df9564095 100644 --- a/packages/SystemUI/res/values/strings.xml +++ b/packages/SystemUI/res/values/strings.xml @@ -233,10 +233,16 @@ <!-- Notification text displayed when we fail to take a screenshot. [CHAR LIMIT=100] --> <string name="screenshot_failed_to_capture_text">Taking screenshots isn\'t allowed by the app or your organization</string> + <!-- Label for UI element which allows editing the screenshot [CHAR LIMIT=30] --> + <string name="screenshot_edit_label">Edit</string> <!-- Content description indicating that tapping the element will allow editing the screenshot [CHAR LIMIT=NONE] --> - <string name="screenshot_edit">Edit screenshot</string> + <string name="screenshot_edit_description">Edit screenshot</string> + <!-- Label for UI element which allows scrolling and extending the screenshot to be taller [CHAR LIMIT=30] --> + <string name="screenshot_scroll_label">Scroll</string> + <!-- Content description UI element which allows scrolling and extending the screenshot to be taller [CHAR LIMIT=NONE] --> + <string name="screenshot_scroll_description">Scroll screenshot</string> <!-- Content description indicating that tapping a button will dismiss the screenshots UI [CHAR LIMIT=NONE] --> - <string name="screenshot_dismiss_ui_description">Dismiss screenshot</string> + <string name="screenshot_dismiss_description">Dismiss screenshot</string> <!-- Content description indicating that the view is a preview of the screenshot that was just taken [CHAR LIMIT=NONE] --> <string name="screenshot_preview_description">Screenshot preview</string> diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotController.java b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotController.java index 260f55799e0b..0dde931d78b2 100644 --- a/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotController.java +++ b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotController.java @@ -27,7 +27,6 @@ import android.animation.AnimatorListenerAdapter; import android.annotation.Nullable; import android.annotation.SuppressLint; import android.app.Notification; -import android.app.WindowContext; import android.content.ComponentName; import android.content.Context; import android.content.res.Configuration; @@ -43,6 +42,7 @@ 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; @@ -58,8 +58,10 @@ 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.systemui.R; +import com.android.systemui.util.DeviceConfigProxy; import java.util.List; import java.util.function.Consumer; @@ -143,6 +145,8 @@ public class ScreenshotController { private final DisplayMetrics mDisplayMetrics; private final AccessibilityManager mAccessibilityManager; private final MediaActionSound mCameraSound; + private final ScrollCaptureClient mScrollCaptureClient; + private final DeviceConfigProxy mConfigProxy; private final Binder mWindowToken; private ScreenshotView mScreenshotView; @@ -173,11 +177,16 @@ public class ScreenshotController { }; @Inject - ScreenshotController(Context context, ScreenshotSmartActions screenshotSmartActions, + ScreenshotController( + Context context, + ScreenshotSmartActions screenshotSmartActions, ScreenshotNotificationsController screenshotNotificationsController, - UiEventLogger uiEventLogger) { + ScrollCaptureClient scrollCaptureClient, + UiEventLogger uiEventLogger, + DeviceConfigProxy configProxy) { mScreenshotSmartActions = screenshotSmartActions; mNotificationsController = screenshotNotificationsController; + mScrollCaptureClient = scrollCaptureClient; mUiEventLogger = uiEventLogger; final DisplayManager dm = requireNonNull(context.getSystemService(DisplayManager.class)); @@ -186,6 +195,7 @@ public class ScreenshotController { mWindowManager = mContext.getSystemService(WindowManager.class); mAccessibilityManager = AccessibilityManager.getInstance(mContext); + mConfigProxy = configProxy; reloadAssets(); Configuration config = mContext.getResources().getConfiguration(); @@ -193,6 +203,7 @@ public class ScreenshotController { 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( @@ -455,6 +466,19 @@ public class ScreenshotController { // 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, + () -> dismissScreenshot(false)))); + } + } + + private void runScrollCapture(ScrollCaptureClient.Connection connection, + Runnable after) { + new ScrollCaptureController(mContext, connection).run(after); } /** diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotView.java b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotView.java index 29f6e8b0db00..3383f80cd2b0 100644 --- a/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotView.java +++ b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotView.java @@ -113,6 +113,7 @@ public class ScreenshotView extends FrameLayout implements private FrameLayout mDismissButton; private ScreenshotActionChip mShareChip; private ScreenshotActionChip mEditChip; + private ScreenshotActionChip mScrollChip; private final ArrayList<ScreenshotActionChip> mSmartChips = new ArrayList<>(); private PendingInteraction mPendingInteraction; @@ -152,6 +153,20 @@ public class ScreenshotView extends FrameLayout implements mContext.getDisplay().getRealMetrics(mDisplayMetrics); } + /** + * Called to display the scroll action chip when support is detected. + * + * @param onClick the action to take when the chip is clicked. + */ + public void showScrollChip(Runnable onClick) { + mScrollChip.setVisibility(VISIBLE); + mScrollChip.setOnClickListener((v) -> + onClick.run() + // TODO Logging, store event consumer to a field + //onElementTapped.accept(ScreenshotEvent.SCREENSHOT_SCROLL_TAPPED); + ); + } + @Override // ViewTreeObserver.OnComputeInternalInsetsListener public void onComputeInternalInsets(ViewTreeObserver.InternalInsetsInfo inoutInfo) { inoutInfo.setTouchableInsets(ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_REGION); @@ -193,6 +208,7 @@ public class ScreenshotView extends FrameLayout implements mScreenshotSelectorView = requireNonNull(findViewById(R.id.global_screenshot_selector)); mShareChip = requireNonNull(mActionsContainer.findViewById(R.id.screenshot_share_chip)); mEditChip = requireNonNull(mActionsContainer.findViewById(R.id.screenshot_edit_chip)); + mScrollChip = requireNonNull(mActionsContainer.findViewById(R.id.screenshot_scroll_chip)); mScreenshotPreview.setClipToOutline(true); mScreenshotPreview.setOutlineProvider(new ViewOutlineProvider() { @@ -387,7 +403,7 @@ public class ScreenshotView extends FrameLayout implements }); chips.add(mShareChip); - mEditChip.setText(mContext.getString(com.android.internal.R.string.screenshot_edit)); + mEditChip.setText(mContext.getString(R.string.screenshot_edit_label)); mEditChip.setIcon(Icon.createWithResource(mContext, R.drawable.ic_screenshot_edit), true); mEditChip.setOnClickListener(v -> { mEditChip.setIsPending(true); @@ -402,6 +418,11 @@ public class ScreenshotView extends FrameLayout implements mPendingInteraction = PendingInteraction.PREVIEW; }); + mScrollChip.setText(mContext.getString(R.string.screenshot_scroll_label)); + mScrollChip.setIcon(Icon.createWithResource(mContext, + R.drawable.ic_screenshot_scroll), true); + chips.add(mScrollChip); + // remove the margin from the last chip so that it's correctly aligned with the end LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) mActionsView.getChildAt(0).getLayoutParams(); diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ScrollCaptureClient.java b/packages/SystemUI/src/com/android/systemui/screenshot/ScrollCaptureClient.java new file mode 100644 index 000000000000..ea835fa94fe8 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/screenshot/ScrollCaptureClient.java @@ -0,0 +1,346 @@ +/* + * 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 java.util.Objects.requireNonNull; + +import android.annotation.UiContext; +import android.app.ActivityTaskManager; +import android.content.Context; +import android.graphics.PixelFormat; +import android.graphics.Point; +import android.graphics.Rect; +import android.hardware.HardwareBuffer; +import android.media.Image; +import android.media.ImageReader; +import android.os.IBinder; +import android.os.RemoteException; +import android.util.Log; +import android.view.IScrollCaptureCallbacks; +import android.view.IScrollCaptureConnection; +import android.view.IWindowManager; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.view.ScrollCaptureViewSupport; + +import java.util.function.Consumer; + +import javax.inject.Inject; + +/** + * High level interface to scroll capture API. + */ +public class ScrollCaptureClient { + + @VisibleForTesting + static final int MATCH_ANY_TASK = ActivityTaskManager.INVALID_TASK_ID; + + private static final String TAG = "ScrollCaptureClient"; + + /** Whether to log method names and arguments for most calls */ + private static final boolean DEBUG_TRACE = false; + + /** + * A connection to a remote window. Starts a capture session. + */ + public interface Connection { + /** + * Session start should be deferred until UI is active because of resource allocation and + * potential visible side effects in the target window. + * + * @param maxBuffers the maximum number of buffers (tiles) that may be in use at one + * time, tiles are not cached anywhere so set this to a large enough + * number to retain offscreen content until it is no longer needed + * @param sessionConsumer listener to receive the session once active + */ + void start(int maxBuffers, Consumer<Session> sessionConsumer); + + /** + * Close the connection. + */ + void close(); + } + + static class CaptureResult { + public final Image image; + /** + * The area requested, in content rect space, relative to scroll-bounds. + */ + public final Rect requested; + /** + * The actual area captured, in content rect space, relative to scroll-bounds. This may be + * cropped or empty depending on available content. + */ + public final Rect captured; + + // Error? + + private CaptureResult(Image image, Rect request, Rect captured) { + this.image = image; + this.requested = request; + this.captured = captured; + } + } + + /** + * Represents the connection to a target window and provides a mechanism for requesting tiles. + */ + interface Session { + /** + * Request the given horizontal strip. Values are y-coordinates in captured space, relative + * to start position. + * + * @param contentRect the area to capture, in content rect space, relative to scroll-bounds + * @param consumer listener to be informed of the result + */ + void requestTile(Rect contentRect, Consumer<CaptureResult> consumer); + + /** + * End the capture session, return the target app to original state. The returned + * stage must be waited for to complete to allow the target app a chance to restore to + * original state before becoming visible. + * + * @return a stage presenting the session shutdown + */ + void end(Runnable listener); + + int getMaxTileHeight(); + + int getMaxTileWidth(); + } + + private final IWindowManager mWindowManagerService; + private IBinder mHostWindowToken; + + @Inject + public ScrollCaptureClient(@UiContext Context context, IWindowManager windowManagerService) { + requireNonNull(context.getDisplay(), "context must be associated with a Display!"); + mWindowManagerService = windowManagerService; + } + + public void setHostWindowToken(IBinder token) { + mHostWindowToken = token; + } + + /** + * Check for scroll capture support. + * + * @param displayId id for the display containing the target window + * @param consumer receives a connection when available + */ + public void request(int displayId, Consumer<Connection> consumer) { + request(displayId, MATCH_ANY_TASK, consumer); + } + + /** + * Check for scroll capture support. + * + * @param displayId id for the display containing the target window + * @param taskId id for the task containing the target window or {@link #MATCH_ANY_TASK}. + * @param consumer receives a connection when available + */ + public void request(int displayId, int taskId, Consumer<Connection> consumer) { + try { + if (DEBUG_TRACE) { + Log.d(TAG, "requestScrollCapture(displayId=" + displayId + ", " + mHostWindowToken + + ", taskId=" + taskId + ", consumer=" + consumer + ")"); + } + mWindowManagerService.requestScrollCapture(displayId, mHostWindowToken, taskId, + new ControllerCallbacks(consumer)); + } catch (RemoteException e) { + Log.e(TAG, "Ignored remote exception", e); + } + } + + private static class ControllerCallbacks extends IScrollCaptureCallbacks.Stub implements + Connection, Session, IBinder.DeathRecipient { + + private IScrollCaptureConnection mConnection; + private Consumer<Connection> mConnectionConsumer; + private Consumer<Session> mSessionConsumer; + private Consumer<CaptureResult> mResultConsumer; + private Runnable mShutdownListener; + + private ImageReader mReader; + private Rect mScrollBounds; + private Rect mRequestRect; + private boolean mStarted; + + private ControllerCallbacks(Consumer<Connection> connectionConsumer) { + mConnectionConsumer = connectionConsumer; + } + + // IScrollCaptureCallbacks + + @Override + public void onConnected(IScrollCaptureConnection connection, Rect scrollBounds, + Point positionInWindow) throws RemoteException { + if (DEBUG_TRACE) { + Log.d(TAG, "onConnected(connection=" + connection + ", scrollBounds=" + scrollBounds + + ", positionInWindow=" + positionInWindow + ")"); + } + mConnection = connection; + mConnection.asBinder().linkToDeath(this, 0); + mScrollBounds = scrollBounds; + mConnectionConsumer.accept(this); + mConnectionConsumer = null; + } + + @Override + public void onUnavailable() throws RemoteException { + if (DEBUG_TRACE) { + Log.d(TAG, "onUnavailable"); + } + // The targeted app does not support scroll capture + // or the window could not be found... etc etc. + } + + @Override + public void onCaptureStarted() { + if (DEBUG_TRACE) { + Log.d(TAG, "onCaptureStarted()"); + } + mSessionConsumer.accept(this); + mSessionConsumer = null; + } + + @Override + public void onCaptureBufferSent(long frameNumber, Rect contentArea) { + Image image = null; + if (frameNumber != ScrollCaptureViewSupport.NO_FRAME_PRODUCED) { + image = mReader.acquireNextImage(); + } + if (DEBUG_TRACE) { + Log.d(TAG, "onCaptureBufferSent(frameNumber=" + frameNumber + + ", contentArea=" + contentArea + ") image=" + image); + } + // Save and clear first, since the consumer will likely request the next + // tile, otherwise the new consumer will be wiped out. + Consumer<CaptureResult> consumer = mResultConsumer; + mResultConsumer = null; + consumer.accept(new CaptureResult(image, mRequestRect, contentArea)); + } + + @Override + public void onConnectionClosed() { + if (DEBUG_TRACE) { + Log.d(TAG, "onConnectionClosed()"); + } + disconnect(); + if (mShutdownListener != null) { + mShutdownListener.run(); + mShutdownListener = null; + } + } + + // Misc + + private void disconnect() { + if (mConnection != null) { + mConnection.asBinder().unlinkToDeath(this, 0); + } + mConnection = null; + } + + // ScrollCaptureController.Connection + + // -> Error handling: BiConsumer<Session, Throwable> ? + @Override + public void start(int maxBufferCount, Consumer<Session> sessionConsumer) { + if (DEBUG_TRACE) { + Log.d(TAG, "start(maxBufferCount=" + maxBufferCount + + ", sessionConsumer=" + sessionConsumer + ")"); + } + mReader = ImageReader.newInstance(mScrollBounds.width(), mScrollBounds.height(), + PixelFormat.RGBA_8888, maxBufferCount, HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE); + mSessionConsumer = sessionConsumer; + try { + mConnection.startCapture(mReader.getSurface()); + mStarted = true; + } catch (RemoteException e) { + Log.w(TAG, "should not be happening :-("); + // ? + //mSessionListener.onError(e); + //mSessionListener = null; + } + } + + @Override + public void close() { + end(null); + } + + // ScrollCaptureController.Session + + @Override + public void end(Runnable listener) { + if (DEBUG_TRACE) { + Log.d(TAG, "end(listener=" + listener + ")"); + } + if (mStarted) { + mShutdownListener = listener; + try { + // listener called from onConnectionClosed callback + mConnection.endCapture(); + } catch (RemoteException e) { + Log.d(TAG, "Ignored exception from endCapture()", e); + disconnect(); + listener.run(); + } + } else { + disconnect(); + listener.run(); + } + } + + @Override + public int getMaxTileHeight() { + return mScrollBounds.height(); + } + + @Override + public int getMaxTileWidth() { + return mScrollBounds.width(); + } + + @Override + public void requestTile(Rect contentRect, Consumer<CaptureResult> consumer) { + if (DEBUG_TRACE) { + Log.d(TAG, "requestTile(contentRect=" + contentRect + "consumer=" + consumer + ")"); + } + mRequestRect = new Rect(contentRect); + mResultConsumer = consumer; + try { + mConnection.requestImage(mRequestRect); + } catch (RemoteException e) { + Log.e(TAG, "Caught remote exception from requestImage", e); + // ? + } + } + + /** + * The process hosting the window went away abruptly! + */ + @Override + public void binderDied() { + if (DEBUG_TRACE) { + Log.d(TAG, "binderDied()"); + } + disconnect(); + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ScrollCaptureController.java b/packages/SystemUI/src/com/android/systemui/screenshot/ScrollCaptureController.java index 5ced40cb1b3b..800d67969f8b 100644 --- a/packages/SystemUI/src/com/android/systemui/screenshot/ScrollCaptureController.java +++ b/packages/SystemUI/src/com/android/systemui/screenshot/ScrollCaptureController.java @@ -16,46 +16,231 @@ package com.android.systemui.screenshot; -import android.os.IBinder; -import android.view.IWindowManager; +import static android.graphics.ColorSpace.Named.SRGB; -import javax.inject.Inject; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.ColorSpace; +import android.graphics.Picture; +import android.graphics.Rect; +import android.media.ExifInterface; +import android.media.Image; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.os.ParcelFileDescriptor; +import android.os.UserHandle; +import android.provider.MediaStore; +import android.text.format.DateUtils; +import android.util.Log; +import android.widget.Toast; + +import com.android.systemui.screenshot.ScrollCaptureClient.Connection; +import com.android.systemui.screenshot.ScrollCaptureClient.Session; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.sql.Date; +import java.text.SimpleDateFormat; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Objects; +import java.util.UUID; +import java.util.function.Consumer; /** - * Stub + * Interaction controller between the UI and ScrollCaptureClient. */ public class ScrollCaptureController { + private static final String TAG = "ScrollCaptureController"; - public static final int STATUS_A = 0; - public static final int STATUS_B = 1; + public static final int MAX_PAGES = 5; + public static final int MAX_HEIGHT = 12000; - private final IWindowManager mWindowManagerService; - private StatusListener mListener; + private final Connection mConnection; + private final Context mContext; + private Picture mPicture; + + public ScrollCaptureController(Context context, Connection connection) { + mContext = context; + mConnection = connection; + } /** + * Run scroll capture! * - * @param windowManagerService + * @param after action to take after the flow is complete */ - @Inject - public ScrollCaptureController(IWindowManager windowManagerService) { - mWindowManagerService = windowManagerService; + public void run(final Runnable after) { + mConnection.start(MAX_PAGES, (session) -> startCapture(session, after)); } - interface StatusListener { - void onScrollCaptureStatus(boolean available); - } + private void startCapture(Session session, final Runnable after) { + Rect requestRect = new Rect(0, 0, + session.getMaxTileWidth(), session.getMaxTileHeight()); + Consumer<ScrollCaptureClient.CaptureResult> consumer = + new Consumer<ScrollCaptureClient.CaptureResult>() { + + int mFrameCount = 0; + + @Override + public void accept(ScrollCaptureClient.CaptureResult result) { + mFrameCount++; + boolean emptyFrame = result.captured.height() == 0; + if (!emptyFrame) { + mPicture = stackBelow(mPicture, result.image, result.captured.width(), + result.captured.height()); + } + if (emptyFrame || mFrameCount > MAX_PAGES + || requestRect.bottom > MAX_HEIGHT) { + Uri uri = null; + if (mPicture != null) { + // This is probably on a binder thread right now ¯\_(ツ)_/¯ + uri = writeImage(Bitmap.createBitmap(mPicture)); + // Release those buffers! + mPicture.close(); + } + if (uri != null) { + launchViewer(uri); + } else { + Toast.makeText(mContext, "Failed to create tall screenshot", + Toast.LENGTH_SHORT).show(); + } + session.end(after); // end session, close connection, after.run() + return; + } + requestRect.offset(0, session.getMaxTileHeight()); + session.requestTile(requestRect, /* consumer */ this); + } + }; + + // fire it up! + session.requestTile(requestRect, consumer); + }; + /** + * Combine the top {@link Picture} with an {@link Image} by appending the image directly + * below, creating a result that is the combined height of both. + * <p> + * Note: no pixel data is transferred here, only a record of drawing commands. Backing + * hardware buffers must not be modified/recycled until the picture is + * {@link Picture#close closed}. + * + * @param top the existing picture + * @param below the image to append below + * @param cropWidth the width of the pixel data to use from the image + * @param cropHeight the height of the pixel data to use from the image * - * @param window - * @param listener + * @return a new Picture which draws the previous picture with the image below it */ - public void getStatus(IBinder window, StatusListener listener) { - mListener = listener; -// try { -// mWindowManagerService.requestScrollCapture(window, new ClientCallbacks()); -// } catch (RemoteException e) { -// } + private static Picture stackBelow(Picture top, Image below, int cropWidth, int cropHeight) { + int width = cropWidth; + int height = cropHeight; + if (top != null) { + height += top.getHeight(); + width = Math.max(width, top.getWidth()); + } + Picture combined = new Picture(); + Canvas canvas = combined.beginRecording(width, height); + int y = 0; + if (top != null) { + canvas.drawPicture(top, new Rect(0, 0, top.getWidth(), top.getHeight())); + y += top.getHeight(); + } + canvas.drawBitmap(Bitmap.wrapHardwareBuffer( + below.getHardwareBuffer(), ColorSpace.get(SRGB)), 0, y, null); + combined.endRecording(); + return combined; } + Uri writeImage(Bitmap image) { + ContentResolver resolver = mContext.getContentResolver(); + long mImageTime = System.currentTimeMillis(); + String imageDate = new SimpleDateFormat("yyyyMMdd-HHmmss").format(new Date(mImageTime)); + String mImageFileName = String.format("tall_Screenshot_%s.png", imageDate); + String mScreenshotId = String.format("Screenshot_%s", UUID.randomUUID()); + try { + // Save the screenshot to the MediaStore + final ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + + File.separator + Environment.DIRECTORY_SCREENSHOTS); + values.put(MediaStore.MediaColumns.DISPLAY_NAME, mImageFileName); + values.put(MediaStore.MediaColumns.MIME_TYPE, "image/png"); + values.put(MediaStore.MediaColumns.DATE_ADDED, mImageTime / 1000); + values.put(MediaStore.MediaColumns.DATE_MODIFIED, mImageTime / 1000); + values.put( + MediaStore.MediaColumns.DATE_EXPIRES, + (mImageTime + DateUtils.DAY_IN_MILLIS) / 1000); + values.put(MediaStore.MediaColumns.IS_PENDING, 1); + + final Uri uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + values); + try { + try (OutputStream out = resolver.openOutputStream(uri)) { + if (!image.compress(Bitmap.CompressFormat.PNG, 100, out)) { + throw new IOException("Failed to compress"); + } + } + + // Next, write metadata to help index the screenshot + try (ParcelFileDescriptor pfd = resolver.openFile(uri, "rw", null)) { + final ExifInterface exif = new ExifInterface(pfd.getFileDescriptor()); + + exif.setAttribute(ExifInterface.TAG_SOFTWARE, + "Android " + Build.DISPLAY); + + exif.setAttribute(ExifInterface.TAG_IMAGE_WIDTH, + Integer.toString(image.getWidth())); + exif.setAttribute(ExifInterface.TAG_IMAGE_LENGTH, + Integer.toString(image.getHeight())); + + final ZonedDateTime time = ZonedDateTime.ofInstant( + Instant.ofEpochMilli(mImageTime), ZoneId.systemDefault()); + exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, + DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss").format(time)); + exif.setAttribute(ExifInterface.TAG_SUBSEC_TIME_ORIGINAL, + DateTimeFormatter.ofPattern("SSS").format(time)); + + if (Objects.equals(time.getOffset(), ZoneOffset.UTC)) { + exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, "+00:00"); + } else { + exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, + DateTimeFormatter.ofPattern("XXX").format(time)); + } + exif.saveAttributes(); + } + + // Everything went well above, publish it! + values.clear(); + values.put(MediaStore.MediaColumns.IS_PENDING, 0); + values.putNull(MediaStore.MediaColumns.DATE_EXPIRES); + resolver.update(uri, values, null, null); + return uri; + } catch (Exception e) { + resolver.delete(uri, null); + throw e; + } + } catch (Exception e) { + Log.e(TAG, "unable to save screenshot", e); + } + return null; + } + + void launchViewer(Uri uri) { + Intent editIntent = new Intent(Intent.ACTION_VIEW); + editIntent.setType("image/png"); + editIntent.setData(uri); + editIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + editIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + mContext.startActivityAsUser(editIntent, UserHandle.CURRENT); + } } diff --git a/packages/SystemUI/tests/src/com/android/AAAPlusPlusVerifySysuiRequiredTestPropertiesTest.java b/packages/SystemUI/tests/src/com/android/AAAPlusPlusVerifySysuiRequiredTestPropertiesTest.java index 594f0b1a8deb..cbd6e8659e69 100644 --- a/packages/SystemUI/tests/src/com/android/AAAPlusPlusVerifySysuiRequiredTestPropertiesTest.java +++ b/packages/SystemUI/tests/src/com/android/AAAPlusPlusVerifySysuiRequiredTestPropertiesTest.java @@ -116,6 +116,13 @@ public class AAAPlusPlusVerifySysuiRequiredTestPropertiesTest extends SysuiTestC filter.add(s -> s.startsWith("com.android.systemui") || s.startsWith("com.android.keyguard")); + // Screenshots run in an isolated process and should not be run + // with the main process dependency graph because it will not exist + // at runtime and could lead to incorrect tests which assume + // the main SystemUI process. Therefore, exclude this package + // from the base class whitelist. + filter.add(s -> !s.startsWith("com.android.systemui.screenshot")); + try { return scanner.getClassPathEntries(filter); } catch (IOException e) { diff --git a/packages/SystemUI/tests/src/com/android/systemui/screenshot/FakeScrollCaptureConnection.java b/packages/SystemUI/tests/src/com/android/systemui/screenshot/FakeScrollCaptureConnection.java new file mode 100644 index 000000000000..a75c39c33f14 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/screenshot/FakeScrollCaptureConnection.java @@ -0,0 +1,142 @@ +/* + * 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 android.content.pm.ActivityInfo; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.HardwareRenderer; +import android.graphics.Paint; +import android.graphics.RecordingCanvas; +import android.graphics.Rect; +import android.graphics.RenderNode; +import android.os.RemoteException; +import android.view.IScrollCaptureCallbacks; +import android.view.IScrollCaptureConnection; +import android.view.Surface; + +/** + * An IScrollCaptureConnection which returns a sequence of solid filled rectangles in the + * locations requested, in alternating colors. + */ +class FakeScrollCaptureConnection extends IScrollCaptureConnection.Stub { + private final int[] mColors = {Color.RED, Color.GREEN, Color.BLUE}; + private IScrollCaptureCallbacks mCallbacks; + private Surface mSurface; + private Paint mPaint; + private int mNextColor; + private HwuiContext mHwuiContext; + + FakeScrollCaptureConnection(IScrollCaptureCallbacks cb) { + mCallbacks = cb; + } + + @Override + public void startCapture(Surface surface) { + mSurface = surface; + mHwuiContext = new HwuiContext(false, surface); + mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + mPaint.setStyle(Paint.Style.FILL); + try { + mCallbacks.onCaptureStarted(); + } catch (RemoteException e) { + e.rethrowAsRuntimeException(); + } + } + + @Override + public void requestImage(Rect rect) { + Canvas canvas = mHwuiContext.lockCanvas(rect.width(), rect.height()); + mPaint.setColor(mColors[mNextColor]); + canvas.drawRect(rect, mPaint); + mNextColor = (mNextColor++) % mColors.length; + long frameNumber = mSurface.getNextFrameNumber(); + mHwuiContext.unlockAndPost(canvas); + try { + mCallbacks.onCaptureBufferSent(frameNumber, rect); + } catch (RemoteException e) { + e.rethrowAsRuntimeException(); + } + } + + @Override + public void endCapture() { + try { + mCallbacks.onConnectionClosed(); + } catch (RemoteException e) { + e.rethrowAsRuntimeException(); + } finally { + mHwuiContext.destroy(); + mSurface = null; + mCallbacks = null; + } + } + + // From android.view.Surface, but issues render requests synchronously with waitForPresent(true) + private static final class HwuiContext { + private final RenderNode mRenderNode; + private final HardwareRenderer mHardwareRenderer; + private RecordingCanvas mCanvas; + private final boolean mIsWideColorGamut; + + HwuiContext(boolean isWideColorGamut, Surface surface) { + mRenderNode = RenderNode.create("HwuiCanvas", null); + mRenderNode.setClipToBounds(false); + mRenderNode.setForceDarkAllowed(false); + mIsWideColorGamut = isWideColorGamut; + + mHardwareRenderer = new HardwareRenderer(); + mHardwareRenderer.setContentRoot(mRenderNode); + mHardwareRenderer.setSurface(surface, true); + mHardwareRenderer.setColorMode( + isWideColorGamut + ? ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT + : ActivityInfo.COLOR_MODE_DEFAULT); + mHardwareRenderer.setLightSourceAlpha(0.0f, 0.0f); + mHardwareRenderer.setLightSourceGeometry(0.0f, 0.0f, 0.0f, 0.0f); + } + + Canvas lockCanvas(int width, int height) { + if (mCanvas != null) { + throw new IllegalStateException("Surface was already locked!"); + } + mCanvas = mRenderNode.beginRecording(width, height); + return mCanvas; + } + + void unlockAndPost(Canvas canvas) { + if (canvas != mCanvas) { + throw new IllegalArgumentException("canvas object must be the same instance that " + + "was previously returned by lockCanvas"); + } + mRenderNode.endRecording(); + mCanvas = null; + mHardwareRenderer.createRenderRequest() + .setVsyncTime(System.nanoTime()) + .setWaitForPresent(true) // sync! + .syncAndDraw(); + } + + void destroy() { + mHardwareRenderer.destroy(); + } + + boolean isWideColorGamut() { + return mIsWideColorGamut; + } + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/screenshot/ScrollCaptureClientTest.java b/packages/SystemUI/tests/src/com/android/systemui/screenshot/ScrollCaptureClientTest.java new file mode 100644 index 000000000000..4aa730efa91e --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/screenshot/ScrollCaptureClientTest.java @@ -0,0 +1,121 @@ +/* + * 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 com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; + +import static java.util.Objects.requireNonNull; + +import android.content.Context; +import android.graphics.Point; +import android.graphics.Rect; +import android.hardware.display.DisplayManager; +import android.os.RemoteException; +import android.testing.AndroidTestingRunner; +import android.view.Display; +import android.view.IScrollCaptureCallbacks; +import android.view.IWindowManager; + +import androidx.test.filters.SmallTest; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.android.systemui.SysuiTestCase; +import com.android.systemui.screenshot.ScrollCaptureClient.CaptureResult; +import com.android.systemui.screenshot.ScrollCaptureClient.Connection; +import com.android.systemui.screenshot.ScrollCaptureClient.Session; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.Spy; +import org.mockito.stubbing.Answer; + +@SmallTest +@RunWith(AndroidTestingRunner.class) +public class ScrollCaptureClientTest extends SysuiTestCase { + private Context mContext; + private IWindowManager mWm; + + @Spy private TestableConsumer<Session> mSessionConsumer; + @Spy private TestableConsumer<Connection> mConnectionConsumer; + @Spy private TestableConsumer<CaptureResult> mResultConsumer; + @Mock private Runnable mRunnable; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + Context context = InstrumentationRegistry.getInstrumentation().getContext(); + DisplayManager displayManager = requireNonNull( + context.getSystemService(DisplayManager.class)); + mContext = context.createDisplayContext( + displayManager.getDisplay(Display.DEFAULT_DISPLAY)); + mWm = mock(IWindowManager.class); + } + + @Test + public void testBasicClientFlow() throws RemoteException { + doAnswer((Answer<Void>) invocation -> { + IScrollCaptureCallbacks cb = invocation.getArgument(3); + cb.onConnected( + new FakeScrollCaptureConnection(cb), + /* scrollBounds */ new Rect(0, 0, 100, 100), + /* positionInWindow */ new Point(0, 0)); + return null; + }).when(mWm).requestScrollCapture(/* displayId */ anyInt(), /* token */ isNull(), + /* taskId */ anyInt(), any(IScrollCaptureCallbacks.class)); + + // Create client + ScrollCaptureClient client = new ScrollCaptureClient(mContext, mWm); + + client.request(Display.DEFAULT_DISPLAY, mConnectionConsumer); + verify(mConnectionConsumer, timeout(100)).accept(any(Connection.class)); + + Connection conn = mConnectionConsumer.getValue(); + + conn.start(5, mSessionConsumer); + verify(mSessionConsumer, timeout(100)).accept(any(Session.class)); + + Session session = mSessionConsumer.getValue(); + Rect request = new Rect(0, 0, session.getMaxTileWidth(), session.getMaxTileHeight()); + + session.requestTile(request, mResultConsumer); + verify(mResultConsumer, timeout(100)).accept(any(CaptureResult.class)); + + CaptureResult result = mResultConsumer.getValue(); + assertThat(result.requested).isEqualTo(request); + assertThat(result.captured).isEqualTo(result.requested); + assertThat(result.image).isNotNull(); + + session.end(mRunnable); + verify(mRunnable, timeout(100)).run(); + + // TODO verify image + // TODO test threading + // TODO test failures + } + +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/screenshot/TestableConsumer.java b/packages/SystemUI/tests/src/com/android/systemui/screenshot/TestableConsumer.java new file mode 100644 index 000000000000..a554e5f583e7 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/screenshot/TestableConsumer.java @@ -0,0 +1,33 @@ +/* + * 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 java.util.function.Consumer; + +/** Accepts and retains the most recent value for verification */ +class TestableConsumer<T> implements Consumer<T> { + T mValue; + + @Override + public void accept(T t) { + mValue = t; + } + + public T getValue() { + return mValue; + } +} |