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
This commit is contained in:
Mark Renouf
2020-10-21 15:58:56 -04:00
parent 8b93b3d6a4
commit 1bf86b5e5b
14 changed files with 949 additions and 36 deletions

View File

@@ -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>

View File

@@ -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"/>

View File

@@ -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"

View File

@@ -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"/>

View File

@@ -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"/>

View File

@@ -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>

View File

@@ -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);
}
/**

View File

@@ -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();

View File

@@ -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();
}
}
}

View File

@@ -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;
/**
*
* @param windowManagerService
*/
@Inject
public ScrollCaptureController(IWindowManager windowManagerService) {
mWindowManagerService = windowManagerService;
}
interface StatusListener {
void onScrollCaptureStatus(boolean available);
public ScrollCaptureController(Context context, Connection connection) {
mContext = context;
mConnection = connection;
}
/**
* Run scroll capture!
*
* @param window
* @param listener
* @param after action to take after the flow is complete
*/
public void getStatus(IBinder window, StatusListener listener) {
mListener = listener;
// try {
// mWindowManagerService.requestScrollCapture(window, new ClientCallbacks());
// } catch (RemoteException e) {
// }
public void run(final Runnable after) {
mConnection.start(MAX_PAGES, (session) -> startCapture(session, after));
}
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
*
* @return a new Picture which draws the previous picture with the image below it
*/
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);
}
}

View File

@@ -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) {

View File

@@ -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;
}
}
}

View File

@@ -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
}
}

View File

@@ -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;
}
}