summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Mark Renouf <mrenouf@google.com> 2020-10-21 15:58:56 -0400
committer Mark Renouf <mrenouf@google.com> 2020-11-03 20:16:07 -0500
commit1bf86b5e5ba5cca770ab2bf5a88808c4117945cc (patch)
treea7fd26dd6078bf521fa364781bb738f483a23ee3
parent8b93b3d6a4a9dab7f7da5d94c2b06800928829f2 (diff)
Demo: show scroll chip and capture tall screenshot when tapped
The result is shown via intent ACTION_VIEW, after saving to PNG. This change introduces ScrollCaptureClient, a light layer around the raw binder API for scroll capture (which is more disruptive to change). This provides a layer to help adapt patterns between the app interface and system UI, as well as provide an optimal testing strategy. Test: atest ScrollCaptureClientTest Change-Id: Ib6d0f61ae0b4d4dc3df459e07d9eaf34d11d59ed
-rw-r--r--packages/SystemUI/res/drawable/ic_screenshot_scroll.xml25
-rw-r--r--packages/SystemUI/res/layout-land/global_screenshot_preview.xml2
-rw-r--r--packages/SystemUI/res/layout/global_screenshot.xml2
-rw-r--r--packages/SystemUI/res/layout/global_screenshot_preview.xml2
-rw-r--r--packages/SystemUI/res/layout/global_screenshot_static.xml3
-rw-r--r--packages/SystemUI/res/values/strings.xml10
-rw-r--r--packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotController.java30
-rw-r--r--packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotView.java23
-rw-r--r--packages/SystemUI/src/com/android/systemui/screenshot/ScrollCaptureClient.java346
-rw-r--r--packages/SystemUI/src/com/android/systemui/screenshot/ScrollCaptureController.java231
-rw-r--r--packages/SystemUI/tests/src/com/android/AAAPlusPlusVerifySysuiRequiredTestPropertiesTest.java7
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/screenshot/FakeScrollCaptureConnection.java142
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/screenshot/ScrollCaptureClientTest.java121
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/screenshot/TestableConsumer.java33
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;
+ }
+}