/* * 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 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 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 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 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 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 mStartCompleter; private Completer mTileRequestCompleter; private Completer 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 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 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 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; } } }