blob: 93e5021fd6e0821c5c603f5ed9614dac4feefbaa [file] [log] [blame]
/*
* Copyright (C) 2021 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.android.systemui.screenshot.LogConfig.DEBUG_SCROLL;
import static java.lang.Math.min;
import static java.util.Objects.requireNonNull;
import android.annotation.BinderThread;
import android.annotation.UiContext;
import android.app.ActivityTaskManager;
import android.content.Context;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.hardware.HardwareBuffer;
import android.media.Image;
import android.media.ImageReader;
import android.os.DeadObjectException;
import android.os.IBinder;
import android.os.ICancellationSignal;
import android.os.RemoteException;
import android.util.Log;
import android.view.IScrollCaptureCallbacks;
import android.view.IScrollCaptureConnection;
import android.view.IScrollCaptureResponseListener;
import android.view.IWindowManager;
import android.view.ScrollCaptureResponse;
import androidx.concurrent.futures.CallbackToFutureAdapter;
import androidx.concurrent.futures.CallbackToFutureAdapter.Completer;
import com.android.internal.annotations.VisibleForTesting;
import com.android.systemui.dagger.qualifiers.Background;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.concurrent.Executor;
import javax.inject.Inject;
/**
* High(er) level interface to scroll capture API.
*/
public class ScrollCaptureClient {
private static final int TILE_SIZE_PX_MAX = 4 * (1024 * 1024);
private static final int TILES_PER_PAGE = 2; // increase once b/174571735 is addressed
private static final int MAX_TILES = 30;
@VisibleForTesting
static final int MATCH_ANY_TASK = ActivityTaskManager.INVALID_TASK_ID;
private static final String TAG = LogConfig.logTag(ScrollCaptureClient.class);
private final Executor mBgExecutor;
/**
* Represents the connection to a target window and provides a mechanism for requesting tiles.
*/
interface Session {
/**
* Request an image tile at the given position, from top, to top + {@link #getTileHeight()},
* and from left 0, to {@link #getPageWidth()}
*
* @param top the top (y) position of the tile to capture, in content rect space
*/
ListenableFuture<CaptureResult> requestTile(int top);
/**
* Returns the maximum number of tiles which may be requested and retained without
* being {@link Image#close() closed}.
*
* @return the maximum number of open tiles allowed
*/
int getMaxTiles();
/**
* Target pixel height for acquisition this session. Session may yield more or less data
* than this, but acquiring this height is considered sufficient for completion.
*
* @return target height in pixels.
*/
int getTargetHeight();
/**
* @return the height of each image tile
*/
int getTileHeight();
/**
* @return the height of scrollable content being captured
*/
int getPageHeight();
/**
* @return the width of the scrollable page
*/
int getPageWidth();
/**
* @return the bounds on screen of the window being captured.
*/
Rect getWindowBounds();
/**
* End the capture session, return the target app to original state. The returned Future
* will complete once the target app is ready to become visible and interactive.
*/
ListenableFuture<Void> end();
void release();
}
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;
CaptureResult(Image image, Rect request, Rect captured) {
this.image = image;
this.requested = request;
this.captured = captured;
}
@Override
public String toString() {
return "CaptureResult{"
+ "requested=" + requested
+ " (" + requested.width() + "x" + requested.height() + ")"
+ ", captured=" + captured
+ " (" + captured.width() + "x" + captured.height() + ")"
+ ", image=" + image
+ '}';
}
}
private final IWindowManager mWindowManagerService;
private IBinder mHostWindowToken;
@Inject
public ScrollCaptureClient(IWindowManager windowManagerService,
@Background Executor bgExecutor, @UiContext Context context) {
requireNonNull(context.getDisplay(), "context must be associated with a Display!");
mBgExecutor = bgExecutor;
mWindowManagerService = windowManagerService;
}
/**
* Set the window token for the screenshot window/ This is required to avoid targeting our
* window or any above it.
*
* @param token the windowToken of the screenshot window
*/
public void setHostWindowToken(IBinder token) {
mHostWindowToken = token;
}
/**
* Check for scroll capture support.
*
* @param displayId id for the display containing the target window
*/
public ListenableFuture<ScrollCaptureResponse> request(int displayId) {
return request(displayId, MATCH_ANY_TASK);
}
/**
* 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}.
* @return a listenable future providing the response
*/
public ListenableFuture<ScrollCaptureResponse> request(int displayId, int taskId) {
return CallbackToFutureAdapter.getFuture((completer) -> {
try {
mWindowManagerService.requestScrollCapture(displayId, mHostWindowToken, taskId,
new IScrollCaptureResponseListener.Stub() {
@Override
public void onScrollCaptureResponse(ScrollCaptureResponse response) {
completer.set(response);
}
});
} catch (RemoteException e) {
completer.setException(e);
}
return "ScrollCaptureClient#request"
+ "(displayId=" + displayId + ", taskId=" + taskId + ")";
});
}
/**
* Start a scroll capture session.
*
* @param response a response provided from a request containing a connection
* @param maxPages the capture buffer size expressed as a multiple of the content height
* @return a listenable future providing the session
*/
public ListenableFuture<Session> start(ScrollCaptureResponse response, float maxPages) {
IScrollCaptureConnection connection = response.getConnection();
return CallbackToFutureAdapter.getFuture((completer) -> {
if (connection == null || !connection.asBinder().isBinderAlive()) {
completer.setException(new DeadObjectException("No active connection!"));
return "";
}
SessionWrapper session = new SessionWrapper(connection, response.getWindowBounds(),
response.getBoundsInWindow(), maxPages, mBgExecutor);
session.start(completer);
return "IScrollCaptureCallbacks#onCaptureStarted";
});
}
private static class SessionWrapper extends IScrollCaptureCallbacks.Stub implements Session,
IBinder.DeathRecipient, ImageReader.OnImageAvailableListener {
private IScrollCaptureConnection mConnection;
private final Executor mBgExecutor;
private final Object mLock = new Object();
private ImageReader mReader;
private final int mTileHeight;
private final int mTileWidth;
private Rect mRequestRect;
private Rect mCapturedArea;
private Image mCapturedImage;
private boolean mStarted;
private final int mTargetHeight;
private ICancellationSignal mCancellationSignal;
private final Rect mWindowBounds;
private final Rect mBoundsInWindow;
private Completer<Session> mStartCompleter;
private Completer<CaptureResult> mTileRequestCompleter;
private Completer<Void> mEndCompleter;
private SessionWrapper(IScrollCaptureConnection connection, Rect windowBounds,
Rect boundsInWindow, float maxPages, Executor bgExecutor)
throws RemoteException {
mConnection = requireNonNull(connection);
mConnection.asBinder().linkToDeath(SessionWrapper.this, 0);
mWindowBounds = requireNonNull(windowBounds);
mBoundsInWindow = requireNonNull(boundsInWindow);
int pxPerPage = mBoundsInWindow.width() * mBoundsInWindow.height();
int pxPerTile = min(TILE_SIZE_PX_MAX, (pxPerPage / TILES_PER_PAGE));
mTileWidth = mBoundsInWindow.width();
mTileHeight = pxPerTile / mBoundsInWindow.width();
mTargetHeight = (int) (mBoundsInWindow.height() * maxPages);
mBgExecutor = bgExecutor;
if (DEBUG_SCROLL) {
Log.d(TAG, "boundsInWindow: " + mBoundsInWindow);
Log.d(TAG, "tile size: " + mTileWidth + "x" + mTileHeight);
}
}
@Override
public void binderDied() {
Log.d(TAG, "binderDied! The target process just crashed :-(");
// Clean up
mConnection = null;
// Pass along the bad news.
if (mStartCompleter != null) {
mStartCompleter.setException(new DeadObjectException("The remote process died"));
}
if (mTileRequestCompleter != null) {
mTileRequestCompleter.setException(
new DeadObjectException("The remote process died"));
}
if (mEndCompleter != null) {
mEndCompleter.setException(new DeadObjectException("The remote process died"));
}
}
private void start(Completer<Session> completer) {
mReader = ImageReader.newInstance(mTileWidth, mTileHeight, PixelFormat.RGBA_8888,
MAX_TILES, HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE);
mStartCompleter = completer;
mReader.setOnImageAvailableListenerWithExecutor(this, mBgExecutor);
try {
mCancellationSignal = mConnection.startCapture(mReader.getSurface(), this);
completer.addCancellationListener(() -> {
try {
mCancellationSignal.cancel();
} catch (RemoteException e) {
// Ignore
}
}, Runnable::run);
mStarted = true;
} catch (RemoteException e) {
mReader.close();
completer.setException(e);
}
}
@BinderThread
@Override
public void onCaptureStarted() {
Log.d(TAG, "onCaptureStarted");
mStartCompleter.set(this);
}
@Override
public ListenableFuture<CaptureResult> requestTile(int top) {
mRequestRect = new Rect(0, top, mTileWidth, top + mTileHeight);
return CallbackToFutureAdapter.getFuture((completer -> {
if (mConnection == null || !mConnection.asBinder().isBinderAlive()) {
completer.setException(new DeadObjectException("Connection is closed!"));
return "";
}
try {
mTileRequestCompleter = completer;
mCancellationSignal = mConnection.requestImage(mRequestRect);
completer.addCancellationListener(() -> {
try {
mCancellationSignal.cancel();
} catch (RemoteException e) {
// Ignore
}
}, Runnable::run);
} catch (RemoteException e) {
completer.setException(e);
}
return "IScrollCaptureCallbacks#onImageRequestCompleted";
}));
}
@BinderThread
@Override
public void onImageRequestCompleted(int flagsUnused, Rect contentArea) {
synchronized (mLock) {
mCapturedArea = contentArea;
if (mCapturedImage != null || (mCapturedArea == null || mCapturedArea.isEmpty())) {
completeCaptureRequest();
}
}
}
/** @see ImageReader.OnImageAvailableListener */
@Override
public void onImageAvailable(ImageReader reader) {
synchronized (mLock) {
mCapturedImage = mReader.acquireLatestImage();
if (mCapturedArea != null) {
completeCaptureRequest();
}
}
}
/** Produces a result for the caller as soon as both asynchronous results are received. */
private void completeCaptureRequest() {
CaptureResult result =
new CaptureResult(mCapturedImage, mRequestRect, mCapturedArea);
mCapturedImage = null;
mRequestRect = null;
mCapturedArea = null;
mTileRequestCompleter.set(result);
}
@Override
public ListenableFuture<Void> end() {
Log.d(TAG, "end()");
return CallbackToFutureAdapter.getFuture(completer -> {
if (!mStarted) {
try {
mConnection.asBinder().unlinkToDeath(SessionWrapper.this, 0);
mConnection.close();
} catch (RemoteException e) {
/* ignore */
}
mConnection = null;
completer.set(null);
return "";
}
mEndCompleter = completer;
try {
mConnection.endCapture();
} catch (RemoteException e) {
completer.setException(e);
}
return "IScrollCaptureCallbacks#onCaptureEnded";
});
}
public void release() {
mReader.close();
}
@BinderThread
@Override
public void onCaptureEnded() {
try {
mConnection.close();
} catch (RemoteException e) {
/* ignore */
}
mConnection = null;
mEndCompleter.set(null);
}
// Misc
@Override
public int getPageHeight() {
return mBoundsInWindow.height();
}
@Override
public int getPageWidth() {
return mBoundsInWindow.width();
}
@Override
public int getTileHeight() {
return mTileHeight;
}
public Rect getWindowBounds() {
return new Rect(mWindowBounds);
}
public Rect getBoundsInWindow() {
return new Rect(mBoundsInWindow);
}
@Override
public int getTargetHeight() {
return mTargetHeight;
}
@Override
public int getMaxTiles() {
return MAX_TILES;
}
}
}