From 8f1b73e1839803c9d8665846e50a3010697fe8b9 Mon Sep 17 00:00:00 2001 From: Mark Renouf Date: Thu, 28 Jan 2021 15:41:18 -0500 Subject: [PATCH] Long screenshots framework update and API unhide This change includes a series of API review changes. The most significant update is the addition of cancellation signals to API methods where needed. Renames some internal classes and interfaces to support the API changes and to simplify tests and improve quality. Test: numerous presubmits Bug: 175830670 CTS-Coverage-Bug: 180419562 Change-Id: I9b1b950a2779fc902ecf4d6394e3c35171926700 --- core/api/current.txt | 37 ++ .../android/view/IScrollCaptureCallbacks.aidl | 36 +- .../view/IScrollCaptureConnection.aidl | 40 +- .../android/view/ScrollCaptureCallback.java | 103 ++-- .../android/view/ScrollCaptureConnection.java | 407 +++++++------- .../android/view/ScrollCaptureResponse.aidl | 19 + .../android/view/ScrollCaptureResponse.java | 381 ++++++++++++++ .../view/ScrollCaptureSearchResults.java | 271 ++++++++++ .../android/view/ScrollCaptureSession.java | 56 +- .../android/view/ScrollCaptureTarget.java | 50 +- .../view/ScrollCaptureTargetResolver.java | 337 ------------ core/java/android/view/View.java | 53 +- core/java/android/view/ViewGroup.java | 127 ++--- core/java/android/view/ViewRootImpl.java | 138 +++-- core/java/android/view/Window.java | 2 - .../android/internal/view/BaseIWindow.java | 5 +- .../view/ScrollCaptureViewSupport.java | 75 ++- .../view/ScrollCaptureConnectionTest.java | 266 +++------- .../view/ScrollCaptureSearchResultsTest.java | 415 +++++++++++++++ .../view/ScrollCaptureTargetResolverTest.java | 498 ------------------ .../view/TestScrollCaptureCallback.java | 86 +++ .../view/ViewGroupScrollCaptureTest.java | 203 ++++--- .../src/android/view/ViewRootImplTest.java | 33 +- .../wm/shell/common/SystemWindows.java | 10 +- .../screenshot/ScreenshotController.java | 1 + .../screenshot/ScrollCaptureClient.java | 263 +++++---- .../FakeScrollCaptureConnection.java | 20 +- .../screenshot/ScrollCaptureClientTest.java | 11 +- .../screenshot/ScrollCaptureTest.java | 25 +- .../server/wm/WindowManagerService.java | 14 +- 30 files changed, 2194 insertions(+), 1788 deletions(-) create mode 100644 core/java/android/view/ScrollCaptureResponse.aidl create mode 100644 core/java/android/view/ScrollCaptureResponse.java create mode 100644 core/java/android/view/ScrollCaptureSearchResults.java delete mode 100644 core/java/android/view/ScrollCaptureTargetResolver.java create mode 100644 core/tests/coretests/src/android/view/ScrollCaptureSearchResultsTest.java delete mode 100644 core/tests/coretests/src/android/view/ScrollCaptureTargetResolverTest.java create mode 100644 core/tests/coretests/src/android/view/TestScrollCaptureCallback.java diff --git a/core/api/current.txt b/core/api/current.txt index d26eb3c79d4d5..9eac08377c5df 100644 --- a/core/api/current.txt +++ b/core/api/current.txt @@ -48037,6 +48037,32 @@ package android.view { method public void onScaleEnd(android.view.ScaleGestureDetector); } + @UiThread public interface ScrollCaptureCallback { + method public void onScrollCaptureEnd(@NonNull Runnable); + method public void onScrollCaptureImageRequest(@NonNull android.view.ScrollCaptureSession, @NonNull android.os.CancellationSignal, @NonNull android.graphics.Rect, @NonNull java.util.function.Consumer); + method public void onScrollCaptureSearch(@NonNull android.os.CancellationSignal, @NonNull java.util.function.Consumer); + method public void onScrollCaptureStart(@NonNull android.view.ScrollCaptureSession, @NonNull android.os.CancellationSignal, @NonNull Runnable); + } + + public class ScrollCaptureSession { + ctor public ScrollCaptureSession(@NonNull android.view.Surface, @NonNull android.graphics.Rect, @NonNull android.graphics.Point); + method @NonNull public android.graphics.Point getPositionInWindow(); + method @NonNull public android.graphics.Rect getScrollBounds(); + method @NonNull public android.view.Surface getSurface(); + } + + public final class ScrollCaptureTarget { + ctor public ScrollCaptureTarget(@NonNull android.view.View, @NonNull android.graphics.Rect, @NonNull android.graphics.Point, @NonNull android.view.ScrollCaptureCallback); + method @NonNull public android.view.ScrollCaptureCallback getCallback(); + method @NonNull public android.view.View getContainingView(); + method public int getHint(); + method @NonNull public android.graphics.Rect getLocalVisibleRect(); + method @NonNull public android.graphics.Point getPositionInWindow(); + method @Nullable public android.graphics.Rect getScrollBounds(); + method public void setScrollBounds(@Nullable android.graphics.Rect); + method @UiThread public void updatePositionInWindow(); + } + public class SearchEvent { ctor public SearchEvent(android.view.InputDevice); method public android.view.InputDevice getInputDevice(); @@ -48363,6 +48389,7 @@ package android.view { method public void dispatchProvideStructure(android.view.ViewStructure); method protected void dispatchRestoreInstanceState(android.util.SparseArray); method protected void dispatchSaveInstanceState(android.util.SparseArray); + method public void dispatchScrollCaptureSearch(@NonNull android.graphics.Rect, @NonNull android.graphics.Point, @NonNull java.util.function.Consumer); method protected void dispatchSetActivated(boolean); method protected void dispatchSetPressed(boolean); method protected void dispatchSetSelected(boolean); @@ -48522,6 +48549,7 @@ package android.view { method public int getScrollBarFadeDuration(); method public int getScrollBarSize(); method public int getScrollBarStyle(); + method public int getScrollCaptureHint(); method public int getScrollIndicators(); method public final int getScrollX(); method public final int getScrollY(); @@ -48690,6 +48718,7 @@ package android.view { method public void onRtlPropertiesChanged(int); method @CallSuper @Nullable protected android.os.Parcelable onSaveInstanceState(); method public void onScreenStateChanged(int); + method public void onScrollCaptureSearch(@NonNull android.graphics.Rect, @NonNull android.graphics.Point, @NonNull java.util.function.Consumer); method protected void onScrollChanged(int, int, int, int); method protected boolean onSetAlpha(int); method protected void onSizeChanged(int, int, int, int); @@ -48872,6 +48901,8 @@ package android.view { method public void setScrollBarFadeDuration(int); method public void setScrollBarSize(int); method public void setScrollBarStyle(int); + method public final void setScrollCaptureCallback(@Nullable android.view.ScrollCaptureCallback); + method public void setScrollCaptureHint(int); method public void setScrollContainer(boolean); method public void setScrollIndicators(int); method public void setScrollIndicators(int, int); @@ -49050,6 +49081,10 @@ package android.view { field public static final int SCROLL_AXIS_HORIZONTAL = 1; // 0x1 field public static final int SCROLL_AXIS_NONE = 0; // 0x0 field public static final int SCROLL_AXIS_VERTICAL = 2; // 0x2 + field public static final int SCROLL_CAPTURE_HINT_AUTO = 0; // 0x0 + field public static final int SCROLL_CAPTURE_HINT_EXCLUDE = 1; // 0x1 + field public static final int SCROLL_CAPTURE_HINT_EXCLUDE_DESCENDANTS = 4; // 0x4 + field public static final int SCROLL_CAPTURE_HINT_INCLUDE = 2; // 0x2 field public static final int SCROLL_INDICATOR_BOTTOM = 2; // 0x2 field public static final int SCROLL_INDICATOR_END = 32; // 0x20 field public static final int SCROLL_INDICATOR_LEFT = 4; // 0x4 @@ -49834,6 +49869,7 @@ package android.view { method public abstract boolean performContextMenuIdentifierAction(int, int); method public abstract boolean performPanelIdentifierAction(int, int, int); method public abstract boolean performPanelShortcut(int, int, android.view.KeyEvent, int); + method public void registerScrollCaptureCallback(@NonNull android.view.ScrollCaptureCallback); method public final void removeOnFrameMetricsAvailableListener(android.view.Window.OnFrameMetricsAvailableListener); method public boolean requestFeature(int); method @NonNull public final T requireViewById(@IdRes int); @@ -49913,6 +49949,7 @@ package android.view { method public abstract void takeKeyEvents(boolean); method public abstract void takeSurface(android.view.SurfaceHolder.Callback2); method public abstract void togglePanel(int, android.view.KeyEvent); + method public void unregisterScrollCaptureCallback(@NonNull android.view.ScrollCaptureCallback); field public static final int DECOR_CAPTION_SHADE_AUTO = 0; // 0x0 field public static final int DECOR_CAPTION_SHADE_DARK = 2; // 0x2 field public static final int DECOR_CAPTION_SHADE_LIGHT = 1; // 0x1 diff --git a/core/java/android/view/IScrollCaptureCallbacks.aidl b/core/java/android/view/IScrollCaptureCallbacks.aidl index d97e3c66cc5d4..26eaac0a2bf59 100644 --- a/core/java/android/view/IScrollCaptureCallbacks.aidl +++ b/core/java/android/view/IScrollCaptureCallbacks.aidl @@ -16,12 +16,10 @@ package android.view; -import android.graphics.Point; import android.graphics.Rect; +import android.view.ScrollCaptureResponse; import android.view.Surface; -import android.view.IScrollCaptureConnection; - /** * Asynchronous callback channel for responses to scroll capture requests. * @@ -29,34 +27,30 @@ import android.view.IScrollCaptureConnection; */ interface IScrollCaptureCallbacks { /** - * Scroll capture is available, and a connection has been provided. + * Provides the result of WindowManagerService#requestScrollCapture * - * @param connection a connection to a window process and scrollable content - * @param scrollAreaInWindow the location of scrolling in global (window) coordinate space + * @param response the response which describes the result */ - oneway void onConnected(in IScrollCaptureConnection connection, in Rect scrollBounds, - in Point positionInWindow); + oneway void onScrollCaptureResponse(in ScrollCaptureResponse response); /** - * The window does not support scroll capture. - */ - oneway void onUnavailable(); - - /** - * Called when the remote end has confirmed the request and is ready to begin providing image - * requests. + * Called in reply to IScrollCaptureConnection#startCapture, when the remote end has confirmed + * the request and is ready to begin capturing images. */ oneway void onCaptureStarted(); /** - * Received a response from a capture request. + * Received a response from a capture request. The provided rectangle indicates the portion + * of the requested rectangle which was captured. An empty rectangle indicates that the request + * could not be satisfied (most commonly due to the available scrolling range). + * + * @param flags flags describing additional status of the result + * @param capturedArea the actual area of the image captured */ - oneway void onCaptureBufferSent(long frameNumber, in Rect capturedArea); + oneway void onImageRequestCompleted(int flags, in Rect capturedArea); /** - * Signals that the capture session has completed and the target window may be returned to - * normal interactive use. This may be due to normal shutdown, or after a timeout or other - * unrecoverable state change such as activity lifecycle, window visibility or focus. + * Signals that the capture session has completed and the target window is ready for normal use. */ - oneway void onConnectionClosed(); + oneway void onCaptureEnded(); } diff --git a/core/java/android/view/IScrollCaptureConnection.aidl b/core/java/android/view/IScrollCaptureConnection.aidl index 63a4f48aeb20c..c55e88800393f 100644 --- a/core/java/android/view/IScrollCaptureConnection.aidl +++ b/core/java/android/view/IScrollCaptureConnection.aidl @@ -17,33 +17,45 @@ package android.view; import android.graphics.Rect; +import android.os.ICancellationSignal; import android.view.Surface; /** - * Interface implemented by a client of the Scroll Capture framework to receive requests - * to start, capture images and end the session. + * A remote connection to a scroll capture target. * * {@hide} */ interface IScrollCaptureConnection { /** - * Informs the client that it has been selected for scroll capture and should prepare to - * to begin handling capture requests. - */ - oneway void startCapture(in Surface surface); - - /** - * Request the client capture an image within the provided rectangle. + * Informs the target that it has been selected for scroll capture. * - * @see android.view.ScrollCaptureCallback#onScrollCaptureRequest + * @param surface a return channel for image buffers + * + * @return a cancallation signal which is used cancel the request */ - oneway void requestImage(in Rect captureArea); + ICancellationSignal startCapture(in Surface surface); /** - * Inform the client that capture has ended. The client should shut down and release all - * local resources in use and prepare for return to normal interactive usage. + * Request the target capture an image within the provided rectangle. + * + * @param surface a return channel for image buffers + * @param signal a cancallation signal which can interrupt the request + * + * @return a cancallation signal which is used cancel the request */ - oneway void endCapture(); + ICancellationSignal requestImage(in Rect captureArea); + + /** + * Inform the target that capture has ended. + * + * @return a cancallation signal which is used cancel the request + */ + ICancellationSignal endCapture(); + + /** + * Closes the connection. + */ + oneway void close(); } diff --git a/core/java/android/view/ScrollCaptureCallback.java b/core/java/android/view/ScrollCaptureCallback.java index d3aad2c72d27e..16886160baca8 100644 --- a/core/java/android/view/ScrollCaptureCallback.java +++ b/core/java/android/view/ScrollCaptureCallback.java @@ -19,6 +19,7 @@ package android.view; import android.annotation.NonNull; import android.annotation.UiThread; import android.graphics.Rect; +import android.os.CancellationSignal; import java.util.function.Consumer; @@ -58,8 +59,6 @@ import java.util.function.Consumer; * @see View#setScrollCaptureHint(int) * @see View#setScrollCaptureCallback(ScrollCaptureCallback) * @see Window#registerScrollCaptureCallback(ScrollCaptureCallback) - * - * @hide */ @UiThread public interface ScrollCaptureCallback { @@ -68,80 +67,84 @@ public interface ScrollCaptureCallback { * The system is searching for the appropriate scrolling container to capture and would like to * know the size and position of scrolling content handled by this callback. *

- * Implementations should inset {@code containingViewBounds} to cover only the area within the - * containing view where scrolling content may be positioned. This should cover only the content - * which tracks with scrolling movement. + * To determine scroll bounds, an implementation should inset the visible bounds of the + * containing view to cover only the area where scrolling content may be positioned. This + * should cover only the content which tracks with scrolling movement. *

- * Return the updated rectangle to {@code resultConsumer}. If for any reason the scrolling - * content is not available to capture, a {@code null} rectangle may be returned, and this view - * will be excluded as the target for this request. + * Return the updated rectangle to {@link Consumer#accept onReady.accept}. If for any reason the + * scrolling content is not available to capture, a empty rectangle may be returned which will + * exclude this view from consideration. *

- * Responses received after XXXms will be discarded. - *

- * TODO: finalize timeout + * This request may be cancelled via the provided {@link CancellationSignal}. When this happens, + * any future call to {@link Consumer#accept onReady.accept} will have no effect and this + * content will be omitted from the search results. * - * @param onReady consumer for the updated rectangle + * @param signal signal to cancel the operation in progress + * @param onReady consumer for the updated rectangle */ - void onScrollCaptureSearch(@NonNull Consumer onReady); + void onScrollCaptureSearch(@NonNull CancellationSignal signal, @NonNull Consumer onReady); /** * Scroll Capture has selected this callback to provide the scrolling image content. *

- * The onReady signal should be called when ready to begin handling image requests. + * {@link Runnable#run onReady.run} should be called when ready to begin handling image + * requests. + *

+ * This request may be cancelled via the provided {@link CancellationSignal}. When this happens, + * any future call to {@link Runnable#run onReady.run} will have no effect and provided session + * will not be activated. + * + * @param session the current session, resources provided by it are valid for use until the + * {@link #onScrollCaptureEnd(Runnable) session ends} + * @param signal signal to cancel the operation in progress + * @param onReady signal used to report completion of the request */ - void onScrollCaptureStart(@NonNull ScrollCaptureSession session, @NonNull Runnable onReady); + void onScrollCaptureStart(@NonNull ScrollCaptureSession session, + @NonNull CancellationSignal signal, @NonNull Runnable onReady); /** * An image capture has been requested from the scrolling content. *

- * captureArea contains the bounds of the image requested, relative to the - * rectangle provided by {@link ScrollCaptureCallback#onScrollCaptureSearch}, referred to as - * {@code scrollBounds}. - * here. + * The requested rectangle describes an area inside the target view, relative to + * scrollBounds. The content may be offscreen, above or below the current visible + * portion of the target view. To handle the request, render the available portion of this + * rectangle to a buffer and return it via the Surface available from {@link + * ScrollCaptureSession#getSurface()}. *

- * A series of requests will step by a constant vertical amount relative to {@code - * scrollBounds}, moving through the scrolling range of content, above and below the current - * visible area. The rectangle's vertical position will not account for any scrolling movement - * since capture started. Implementations therefore must track any scroll position changes and - * subtract this distance from requests. + * Note: Implementations are only required to render the requested content, and may do so into + * off-screen buffers without scrolling if they are able. *

- * To handle a request, the content should be scrolled to maximize the visible area of the - * requested rectangle. Offset {@code captureArea} again to account for any further scrolling. - *

- * Finally, clip this rectangle against scrollBounds to determine what portion, if any is - * visible content to capture. If the rectangle is completely clipped, set it to {@link - * Rect#setEmpty() empty} and skip the next step. - *

- * Make a copy of {@code captureArea}, transform to window coordinates and draw the window, - * clipped to this rectangle, into the {@link ScrollCaptureSession#getSurface() surface} at - * offset (0,0). - *

- * Finally, return the resulting {@code captureArea} using - * {@link ScrollCaptureSession#notifyBufferSent}. - *

- * If the response is not supplied within XXXms, the session will end with a call to {@link - * #onScrollCaptureEnd}, after which {@code session} is invalid and should be discarded. - *

- * TODO: finalize timeout + * The resulting available portion of the request must be computed as a portion of {@code + * captureArea}, and sent to signal the operation is complete, using {@link Consumer#accept + * onComplete.accept}. If the requested rectangle is partially or fully out of bounds the + * resulting portion should be returned. If no portion is available (outside of available + * content), then skip sending any buffer and report an empty Rect as result. *

+ * This request may be cancelled via the provided {@link CancellationSignal}. When this happens, + * any future call to {@link Consumer#accept onComplete.accept} will be ignored until the next + * request. * + * @param session the current session, resources provided by it are valid for use until the + * {@link #onScrollCaptureEnd(Runnable) session ends} + * @param signal signal to cancel the operation in progress * @param captureArea the area to capture, a rectangle within {@code scrollBounds} + * @param onComplete a consumer for the captured area */ - void onScrollCaptureImageRequest( - @NonNull ScrollCaptureSession session, @NonNull Rect captureArea); + void onScrollCaptureImageRequest(@NonNull ScrollCaptureSession session, + @NonNull CancellationSignal signal, @NonNull Rect captureArea, + @NonNull Consumer onComplete); /** * Signals that capture has ended. Implementations should release any temporary resources or * references to objects in use during the capture. Any resources obtained from the session are * now invalid and attempts to use them after this point may throw an exception. *

- * The window should be returned as much as possible to its original state when capture started. - * At a minimum, the content should be scrolled to its original position. + * The window should be returned to its original state when capture started. At a minimum, the + * content should be scrolled to its original position. *

- * onReady should be called when the window should be made visible and - * interactive. The system will wait up to XXXms for this call before proceeding. - *

- * TODO: finalize timeout + * {@link Runnable#run onReady.run} should be called as soon as possible after the window is + * ready for normal interactive use. After the callback (or after a timeout, if not called) the + * screenshot tool will be dismissed and the window may become visible to the user at any time. * * @param onReady a callback to inform the system that the application has completed any * cleanup and is ready to become visible diff --git a/core/java/android/view/ScrollCaptureConnection.java b/core/java/android/view/ScrollCaptureConnection.java index 0e6cdd1dbec54..3456e016c42c6 100644 --- a/core/java/android/view/ScrollCaptureConnection.java +++ b/core/java/android/view/ScrollCaptureConnection.java @@ -18,18 +18,23 @@ package android.view; import static java.util.Objects.requireNonNull; +import android.annotation.BinderThread; import android.annotation.NonNull; import android.annotation.UiThread; -import android.annotation.WorkerThread; import android.graphics.Point; import android.graphics.Rect; -import android.os.Handler; +import android.os.CancellationSignal; +import android.os.ICancellationSignal; import android.os.RemoteException; +import android.os.Trace; import android.util.CloseGuard; +import android.util.Log; import com.android.internal.annotations.VisibleForTesting; -import java.util.concurrent.atomic.AtomicBoolean; +import java.lang.ref.WeakReference; +import java.util.concurrent.Executor; +import java.util.function.Consumer; /** * Mediator between a selected scroll capture target view and a remote process. @@ -41,270 +46,276 @@ import java.util.concurrent.atomic.AtomicBoolean; public class ScrollCaptureConnection extends IScrollCaptureConnection.Stub { private static final String TAG = "ScrollCaptureConnection"; - private static final int DEFAULT_TIMEOUT = 1000; - - private final Handler mHandler; - private ScrollCaptureTarget mSelectedTarget; - private int mTimeoutMillis = DEFAULT_TIMEOUT; - - protected Surface mSurface; - private IScrollCaptureCallbacks mCallbacks; + private final Object mLock = new Object(); private final Rect mScrollBounds; private final Point mPositionInWindow; private final CloseGuard mCloseGuard; + private final Executor mUiThread; + + private ScrollCaptureCallback mLocal; + private IScrollCaptureCallbacks mRemote; - // The current session instance in use by the callback. private ScrollCaptureSession mSession; - // Helps manage timeout callbacks registered to handler and aids testing. - private DelayedAction mTimeoutAction; + private CancellationSignal mCancellation; + + private volatile boolean mStarted; + private volatile boolean mConnected; /** * Constructs a ScrollCaptureConnection. * * @param selectedTarget the target the client is controlling - * @param callbacks the callbacks to reply to system requests + * @param remote the callbacks to reply to system requests * * @hide */ public ScrollCaptureConnection( + @NonNull Executor uiThread, @NonNull ScrollCaptureTarget selectedTarget, - @NonNull IScrollCaptureCallbacks callbacks) { + @NonNull IScrollCaptureCallbacks remote) { + mUiThread = requireNonNull(uiThread, " must non-null"); requireNonNull(selectedTarget, " must non-null"); - requireNonNull(callbacks, " must non-null"); - final Rect scrollBounds = requireNonNull(selectedTarget.getScrollBounds(), + mRemote = requireNonNull(remote, " must non-null"); + mScrollBounds = requireNonNull(Rect.copyOrNull(selectedTarget.getScrollBounds()), "target.getScrollBounds() must be non-null to construct a client"); - mSelectedTarget = selectedTarget; - mHandler = selectedTarget.getContainingView().getHandler(); - mScrollBounds = new Rect(scrollBounds); + mLocal = selectedTarget.getCallback(); mPositionInWindow = new Point(selectedTarget.getPositionInWindow()); - mCallbacks = callbacks; mCloseGuard = new CloseGuard(); mCloseGuard.open("close"); - - selectedTarget.getContainingView().addOnAttachStateChangeListener( - new View.OnAttachStateChangeListener() { - @Override - public void onViewAttachedToWindow(View v) { - - } - - @Override - public void onViewDetachedFromWindow(View v) { - selectedTarget.getContainingView().removeOnAttachStateChangeListener(this); - endCapture(); - } - }); + mConnected = true; } - @VisibleForTesting - public void setTimeoutMillis(int timeoutMillis) { - mTimeoutMillis = timeoutMillis; - } - - @VisibleForTesting - public DelayedAction getTimeoutAction() { - return mTimeoutAction; - } - - private void checkConnected() { - if (mSelectedTarget == null || mCallbacks == null) { - throw new IllegalStateException("This client has been disconnected."); - } - } - - private void checkStarted() { - if (mSession == null) { - throw new IllegalStateException("Capture session has not been started!"); - } - } - - @WorkerThread // IScrollCaptureConnection + @BinderThread @Override - public void startCapture(Surface surface) throws RemoteException { + public ICancellationSignal startCapture(Surface surface) throws RemoteException { checkConnected(); - mSurface = surface; - scheduleTimeout(mTimeoutMillis, this::onStartCaptureTimeout); - mSession = new ScrollCaptureSession(mSurface, mScrollBounds, mPositionInWindow, this); - mHandler.post(() -> mSelectedTarget.getCallback().onScrollCaptureStart(mSession, - this::onStartCaptureCompleted)); + if (!surface.isValid()) { + throw new RemoteException(new IllegalArgumentException("surface must be valid")); + } + + ICancellationSignal cancellation = CancellationSignal.createTransport(); + mCancellation = CancellationSignal.fromTransport(cancellation); + mSession = new ScrollCaptureSession(surface, mScrollBounds, mPositionInWindow); + + Runnable listener = + SafeCallback.create(mCancellation, mUiThread, this::onStartCaptureCompleted); + // -> UiThread + mUiThread.execute(() -> mLocal.onScrollCaptureStart(mSession, mCancellation, listener)); + return cancellation; } @UiThread private void onStartCaptureCompleted() { - if (cancelTimeout()) { - mHandler.post(() -> { - try { - mCallbacks.onCaptureStarted(); - } catch (RemoteException e) { - doShutdown(); - } - }); + mStarted = true; + try { + mRemote.onCaptureStarted(); + } catch (RemoteException e) { + Log.w(TAG, "Shutting down due to error: ", e); + close(); } } - @UiThread - private void onStartCaptureTimeout() { - endCapture(); - } - @WorkerThread // IScrollCaptureConnection + @BinderThread @Override - public void requestImage(Rect requestRect) { + public ICancellationSignal requestImage(Rect requestRect) throws RemoteException { + Trace.beginSection("requestImage"); checkConnected(); checkStarted(); - scheduleTimeout(mTimeoutMillis, this::onRequestImageTimeout); - // Response is dispatched via ScrollCaptureSession, to onRequestImageCompleted - mHandler.post(() -> mSelectedTarget.getCallback().onScrollCaptureImageRequest( - mSession, new Rect(requestRect))); + + ICancellationSignal cancellation = CancellationSignal.createTransport(); + mCancellation = CancellationSignal.fromTransport(cancellation); + + Consumer listener = + SafeCallback.create(mCancellation, mUiThread, this::onImageRequestCompleted); + // -> UiThread + mUiThread.execute(() -> mLocal.onScrollCaptureImageRequest( + mSession, mCancellation, new Rect(requestRect), listener)); + Trace.endSection(); + return cancellation; } @UiThread - void onRequestImageCompleted(long frameNumber, Rect capturedArea) { - final Rect finalCapturedArea = new Rect(capturedArea); - if (cancelTimeout()) { - mHandler.post(() -> { - try { - mCallbacks.onCaptureBufferSent(frameNumber, finalCapturedArea); - } catch (RemoteException e) { - doShutdown(); - } - }); - } - } - - @UiThread - private void onRequestImageTimeout() { - endCapture(); - } - - @WorkerThread // IScrollCaptureConnection - @Override - public void endCapture() { - if (isStarted()) { - scheduleTimeout(mTimeoutMillis, this::onEndCaptureTimeout); - mHandler.post(() -> - mSelectedTarget.getCallback().onScrollCaptureEnd(this::onEndCaptureCompleted)); - } else { - disconnect(); - } - } - - private boolean isStarted() { - return mCallbacks != null && mSelectedTarget != null; - } - - @UiThread - private void onEndCaptureCompleted() { // onEndCaptureCompleted - if (cancelTimeout()) { - doShutdown(); - } - } - - @UiThread - private void onEndCaptureTimeout() { - doShutdown(); - } - - - private void doShutdown() { + void onImageRequestCompleted(Rect capturedArea) { try { - if (mCallbacks != null) { - mCallbacks.onConnectionClosed(); - } + mRemote.onImageRequestCompleted(0, capturedArea); } catch (RemoteException e) { - // Ignore - } finally { - disconnect(); + Log.w(TAG, "Shutting down due to error: ", e); + close(); } } + @BinderThread + @Override + public ICancellationSignal endCapture() throws RemoteException { + checkConnected(); + checkStarted(); + + ICancellationSignal cancellation = CancellationSignal.createTransport(); + mCancellation = CancellationSignal.fromTransport(cancellation); + + Runnable listener = + SafeCallback.create(mCancellation, mUiThread, this::onEndCaptureCompleted); + // -> UiThread + mUiThread.execute(() -> mLocal.onScrollCaptureEnd(listener)); + return cancellation; + } + + @UiThread + private void onEndCaptureCompleted() { + synchronized (mLock) { + mStarted = false; + try { + mRemote.onCaptureEnded(); + } catch (RemoteException e) { + Log.w(TAG, "Shutting down due to error: ", e); + close(); + } + } + } + + @BinderThread + @Override + public void close() { + if (mStarted) { + Log.w(TAG, "close(): capture is still started?! Ending now."); + + // -> UiThread + mUiThread.execute(() -> mLocal.onScrollCaptureEnd(() -> { /* ignore */ })); + mStarted = false; + } + disconnect(); + } + /** * Shuts down this client and releases references to dependent objects. No attempt is made * to notify the controller, use with caution! */ - public void disconnect() { - if (mSession != null) { - mSession.disconnect(); + private void disconnect() { + synchronized (mLock) { mSession = null; + mConnected = false; + mStarted = false; + mRemote = null; + mLocal = null; + mCloseGuard.close(); } + } - mSelectedTarget = null; - mCallbacks = null; + public boolean isConnected() { + return mConnected; + } + + public boolean isStarted() { + return mStarted; + } + + private synchronized void checkConnected() throws RemoteException { + synchronized (mLock) { + if (!mConnected) { + throw new RemoteException(new IllegalStateException("Not connected")); + } + } + } + + private void checkStarted() throws RemoteException { + synchronized (mLock) { + if (!mStarted) { + throw new RemoteException(new IllegalStateException("Not started!")); + } + } } /** @return a string representation of the state of this client */ public String toString() { return "ScrollCaptureConnection{" + + "connected=" + mConnected + + ", started=" + mStarted + ", session=" + mSession - + ", selectedTarget=" + mSelectedTarget - + ", clientCallbacks=" + mCallbacks + + ", remote=" + mRemote + + ", local=" + mLocal + "}"; } - private boolean cancelTimeout() { - if (mTimeoutAction != null) { - return mTimeoutAction.cancel(); - } - return false; - } - - private void scheduleTimeout(long timeoutMillis, Runnable action) { - if (mTimeoutAction != null) { - mTimeoutAction.cancel(); - } - mTimeoutAction = new DelayedAction(mHandler, timeoutMillis, action); - } - - /** @hide */ @VisibleForTesting - public static class DelayedAction { - private final AtomicBoolean mCompleted = new AtomicBoolean(); - private final Object mToken = new Object(); - private final Handler mHandler; - private final Runnable mAction; + public CancellationSignal getCancellation() { + return mCancellation; + } - @VisibleForTesting - public DelayedAction(Handler handler, long timeoutMillis, Runnable action) { - mHandler = handler; - mAction = action; - mHandler.postDelayed(this::onTimeout, mToken, timeoutMillis); - } - - private boolean onTimeout() { - if (mCompleted.compareAndSet(false, true)) { - mAction.run(); - return true; + protected void finalize() throws Throwable { + try { + if (mCloseGuard != null) { + mCloseGuard.warnIfOpen(); } - return false; + close(); + } finally { + super.finalize(); + } + } + + private static class SafeCallback { + private final CancellationSignal mSignal; + private final WeakReference mTargetRef; + private final Executor mExecutor; + private boolean mExecuted; + + protected SafeCallback(CancellationSignal signal, Executor executor, T target) { + mSignal = signal; + mTargetRef = new WeakReference<>(target); + mExecutor = executor; } - /** - * Cause the timeout action to run immediately and mark as timed out. - * - * @return true if the timeout was run, false if the timeout had already been canceled - */ - @VisibleForTesting - public boolean timeoutNow() { - return onTimeout(); - } - - /** - * Attempt to cancel the timeout action (such as after a callback is made) - * - * @return true if the timeout was canceled and will not run, false if time has expired and - * the timeout action has or will run momentarily - */ - public boolean cancel() { - if (!mCompleted.compareAndSet(false, true)) { - // Whoops, too late! - return false; + // Provide the target to the consumer to invoke, forward on handler thread ONCE, + // and only if noy cancelled, and the target is still available (not collected) + protected final void maybeAccept(Consumer targetConsumer) { + if (mExecuted) { + return; } - mHandler.removeCallbacksAndMessages(mToken); - return true; + mExecuted = true; + if (mSignal.isCanceled()) { + return; + } + T target = mTargetRef.get(); + if (target == null) { + return; + } + mExecutor.execute(() -> targetConsumer.accept(target)); + } + + static Runnable create(CancellationSignal signal, Executor executor, Runnable target) { + return new RunnableCallback(signal, executor, target); + } + + static Consumer create(CancellationSignal signal, Executor executor, + Consumer target) { + return new ConsumerCallback(signal, executor, target); + } + } + + private static final class RunnableCallback extends SafeCallback implements Runnable { + RunnableCallback(CancellationSignal signal, Executor executor, Runnable target) { + super(signal, executor, target); + } + + @Override + public void run() { + maybeAccept(Runnable::run); + } + } + + private static final class ConsumerCallback extends SafeCallback> + implements Consumer { + ConsumerCallback(CancellationSignal signal, Executor executor, Consumer target) { + super(signal, executor, target); + } + + @Override + public void accept(T value) { + maybeAccept((target) -> target.accept(value)); } } } diff --git a/core/java/android/view/ScrollCaptureResponse.aidl b/core/java/android/view/ScrollCaptureResponse.aidl new file mode 100644 index 0000000000000..3de2b80ef16e7 --- /dev/null +++ b/core/java/android/view/ScrollCaptureResponse.aidl @@ -0,0 +1,19 @@ +/* + * 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 android.view; + +parcelable ScrollCaptureResponse; diff --git a/core/java/android/view/ScrollCaptureResponse.java b/core/java/android/view/ScrollCaptureResponse.java new file mode 100644 index 0000000000000..564113edb3c77 --- /dev/null +++ b/core/java/android/view/ScrollCaptureResponse.java @@ -0,0 +1,381 @@ +/* + * 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 android.view; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.graphics.Rect; +import android.os.Parcelable; + +import com.android.internal.util.DataClass; + +import java.util.ArrayList; + +/** @hide */ +@DataClass(genToString = true, genGetters = true) +public class ScrollCaptureResponse implements Parcelable { + + /** Developer-facing human readable description of the result. */ + @NonNull + private String mDescription = ""; + + // Remaining fields are non-null when isConnected() == true + + /** The active connection for a successful result. */ + @Nullable + @DataClass.MaySetToNull + private IScrollCaptureConnection mConnection = null; + + /** The bounds of the window within the display */ + @Nullable + private Rect mWindowBounds = null; + + /** The bounds of the scrolling content, in window space. */ + @Nullable + private Rect mBoundsInWindow = null; + + /** The current window title. */ + @Nullable + private String mWindowTitle = null; + + /** Carries additional logging and debugging information when enabled. */ + @NonNull + @DataClass.PluralOf("message") + private ArrayList mMessages = new ArrayList<>(); + + /** Whether a connection has been returned. */ + public boolean isConnected() { + return mConnection != null; + } + + + + + // Code below generated by codegen v1.0.22. + // + // DO NOT MODIFY! + // CHECKSTYLE:OFF Generated code + // + // To regenerate run: + // $ codegen $ANDROID_BUILD_TOP/frameworks/base/core/java/android/view/ScrollCaptureResponse.java + // + // To exclude the generated code from IntelliJ auto-formatting enable (one-time): + // Settings > Editor > Code Style > Formatter Control + //@formatter:off + + + @DataClass.Generated.Member + /* package-private */ ScrollCaptureResponse( + @NonNull String description, + @Nullable IScrollCaptureConnection connection, + @Nullable Rect windowBounds, + @Nullable Rect boundsInWindow, + @Nullable String windowTitle, + @NonNull ArrayList messages) { + this.mDescription = description; + com.android.internal.util.AnnotationValidations.validate( + NonNull.class, null, mDescription); + this.mConnection = connection; + this.mWindowBounds = windowBounds; + this.mBoundsInWindow = boundsInWindow; + this.mWindowTitle = windowTitle; + this.mMessages = messages; + com.android.internal.util.AnnotationValidations.validate( + NonNull.class, null, mMessages); + + // onConstructed(); // You can define this method to get a callback + } + + /** + * Developer-facing human readable description of the result. + */ + @DataClass.Generated.Member + public @NonNull String getDescription() { + return mDescription; + } + + /** + * The active connection for a successful result. + */ + @DataClass.Generated.Member + public @Nullable IScrollCaptureConnection getConnection() { + return mConnection; + } + + /** + * The bounds of the window within the display + */ + @DataClass.Generated.Member + public @Nullable Rect getWindowBounds() { + return mWindowBounds; + } + + /** + * The bounds of the scrolling content, in window space. + */ + @DataClass.Generated.Member + public @Nullable Rect getBoundsInWindow() { + return mBoundsInWindow; + } + + /** + * The current window title. + */ + @DataClass.Generated.Member + public @Nullable String getWindowTitle() { + return mWindowTitle; + } + + /** + * Carries additional logging and debugging information when enabled. + */ + @DataClass.Generated.Member + public @NonNull ArrayList getMessages() { + return mMessages; + } + + @Override + @DataClass.Generated.Member + public String toString() { + // You can override field toString logic by defining methods like: + // String fieldNameToString() { ... } + + return "ScrollCaptureResponse { " + + "description = " + mDescription + ", " + + "connection = " + mConnection + ", " + + "windowBounds = " + mWindowBounds + ", " + + "boundsInWindow = " + mBoundsInWindow + ", " + + "windowTitle = " + mWindowTitle + ", " + + "messages = " + mMessages + + " }"; + } + + @Override + @DataClass.Generated.Member + public void writeToParcel(@NonNull android.os.Parcel dest, int flags) { + // You can override field parcelling by defining methods like: + // void parcelFieldName(Parcel dest, int flags) { ... } + + byte flg = 0; + if (mConnection != null) flg |= 0x2; + if (mWindowBounds != null) flg |= 0x4; + if (mBoundsInWindow != null) flg |= 0x8; + if (mWindowTitle != null) flg |= 0x10; + dest.writeByte(flg); + dest.writeString(mDescription); + if (mConnection != null) dest.writeStrongInterface(mConnection); + if (mWindowBounds != null) dest.writeTypedObject(mWindowBounds, flags); + if (mBoundsInWindow != null) dest.writeTypedObject(mBoundsInWindow, flags); + if (mWindowTitle != null) dest.writeString(mWindowTitle); + dest.writeStringList(mMessages); + } + + @Override + @DataClass.Generated.Member + public int describeContents() { return 0; } + + /** @hide */ + @SuppressWarnings({"unchecked", "RedundantCast"}) + @DataClass.Generated.Member + protected ScrollCaptureResponse(@NonNull android.os.Parcel in) { + // You can override field unparcelling by defining methods like: + // static FieldType unparcelFieldName(Parcel in) { ... } + + byte flg = in.readByte(); + String description = in.readString(); + IScrollCaptureConnection connection = (flg & 0x2) == 0 ? null : IScrollCaptureConnection.Stub.asInterface(in.readStrongBinder()); + Rect windowBounds = (flg & 0x4) == 0 ? null : (Rect) in.readTypedObject(Rect.CREATOR); + Rect boundsInWindow = (flg & 0x8) == 0 ? null : (Rect) in.readTypedObject(Rect.CREATOR); + String windowTitle = (flg & 0x10) == 0 ? null : in.readString(); + ArrayList messages = new ArrayList<>(); + in.readStringList(messages); + + this.mDescription = description; + com.android.internal.util.AnnotationValidations.validate( + NonNull.class, null, mDescription); + this.mConnection = connection; + this.mWindowBounds = windowBounds; + this.mBoundsInWindow = boundsInWindow; + this.mWindowTitle = windowTitle; + this.mMessages = messages; + com.android.internal.util.AnnotationValidations.validate( + NonNull.class, null, mMessages); + + // onConstructed(); // You can define this method to get a callback + } + + @DataClass.Generated.Member + public static final @NonNull Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public ScrollCaptureResponse[] newArray(int size) { + return new ScrollCaptureResponse[size]; + } + + @Override + public ScrollCaptureResponse createFromParcel(@NonNull android.os.Parcel in) { + return new ScrollCaptureResponse(in); + } + }; + + /** + * A builder for {@link ScrollCaptureResponse} + */ + @SuppressWarnings("WeakerAccess") + @DataClass.Generated.Member + public static class Builder { + + private @NonNull String mDescription; + private @Nullable IScrollCaptureConnection mConnection; + private @Nullable Rect mWindowBounds; + private @Nullable Rect mBoundsInWindow; + private @Nullable String mWindowTitle; + private @NonNull ArrayList mMessages; + + private long mBuilderFieldsSet = 0L; + + public Builder() { + } + + /** + * Developer-facing human readable description of the result. + */ + @DataClass.Generated.Member + public @NonNull Builder setDescription(@NonNull String value) { + checkNotUsed(); + mBuilderFieldsSet |= 0x1; + mDescription = value; + return this; + } + + /** + * The active connection for a successful result. + */ + @DataClass.Generated.Member + public @NonNull Builder setConnection(@Nullable IScrollCaptureConnection value) { + checkNotUsed(); + mBuilderFieldsSet |= 0x2; + mConnection = value; + return this; + } + + /** + * The bounds of the window within the display + */ + @DataClass.Generated.Member + public @NonNull Builder setWindowBounds(@NonNull Rect value) { + checkNotUsed(); + mBuilderFieldsSet |= 0x4; + mWindowBounds = value; + return this; + } + + /** + * The bounds of the scrolling content, in window space. + */ + @DataClass.Generated.Member + public @NonNull Builder setBoundsInWindow(@NonNull Rect value) { + checkNotUsed(); + mBuilderFieldsSet |= 0x8; + mBoundsInWindow = value; + return this; + } + + /** + * The current window title. + */ + @DataClass.Generated.Member + public @NonNull Builder setWindowTitle(@NonNull String value) { + checkNotUsed(); + mBuilderFieldsSet |= 0x10; + mWindowTitle = value; + return this; + } + + /** + * Carries additional logging and debugging information when enabled. + */ + @DataClass.Generated.Member + public @NonNull Builder setMessages(@NonNull ArrayList value) { + checkNotUsed(); + mBuilderFieldsSet |= 0x20; + mMessages = value; + return this; + } + + /** @see #setMessages */ + @DataClass.Generated.Member + public @NonNull Builder addMessage(@NonNull String value) { + if (mMessages == null) setMessages(new ArrayList<>()); + mMessages.add(value); + return this; + } + + /** Builds the instance. This builder should not be touched after calling this! */ + public @NonNull ScrollCaptureResponse build() { + checkNotUsed(); + mBuilderFieldsSet |= 0x40; // Mark builder used + + if ((mBuilderFieldsSet & 0x1) == 0) { + mDescription = ""; + } + if ((mBuilderFieldsSet & 0x2) == 0) { + mConnection = null; + } + if ((mBuilderFieldsSet & 0x4) == 0) { + mWindowBounds = null; + } + if ((mBuilderFieldsSet & 0x8) == 0) { + mBoundsInWindow = null; + } + if ((mBuilderFieldsSet & 0x10) == 0) { + mWindowTitle = null; + } + if ((mBuilderFieldsSet & 0x20) == 0) { + mMessages = new ArrayList<>(); + } + ScrollCaptureResponse o = new ScrollCaptureResponse( + mDescription, + mConnection, + mWindowBounds, + mBoundsInWindow, + mWindowTitle, + mMessages); + return o; + } + + private void checkNotUsed() { + if ((mBuilderFieldsSet & 0x40) != 0) { + throw new IllegalStateException( + "This Builder should not be reused. Use a new Builder instance instead"); + } + } + } + + @DataClass.Generated( + time = 1612282689462L, + codegenVersion = "1.0.22", + sourceFile = "frameworks/base/core/java/android/view/ScrollCaptureResponse.java", + inputSignatures = "private @android.annotation.NonNull java.lang.String mDescription\nprivate @android.annotation.Nullable @com.android.internal.util.DataClass.MaySetToNull android.view.IScrollCaptureConnection mConnection\nprivate @android.annotation.Nullable android.graphics.Rect mWindowBounds\nprivate @android.annotation.Nullable android.graphics.Rect mBoundsInWindow\nprivate @android.annotation.Nullable java.lang.String mWindowTitle\nprivate @android.annotation.NonNull @com.android.internal.util.DataClass.PluralOf(\"message\") java.util.ArrayList mMessages\npublic boolean isConnected()\nclass ScrollCaptureResponse extends java.lang.Object implements [android.os.Parcelable]\n@com.android.internal.util.DataClass(genToString=true, genGetters=true)") + @Deprecated + private void __metadata() {} + + + //@formatter:on + // End of generated code + +} diff --git a/core/java/android/view/ScrollCaptureSearchResults.java b/core/java/android/view/ScrollCaptureSearchResults.java new file mode 100644 index 0000000000000..3469b9dc7103e --- /dev/null +++ b/core/java/android/view/ScrollCaptureSearchResults.java @@ -0,0 +1,271 @@ +/* + * 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 android.view; + +import static java.util.Objects.requireNonNull; + +import android.annotation.NonNull; +import android.annotation.UiThread; +import android.graphics.Rect; +import android.os.CancellationSignal; +import android.util.IndentingPrintWriter; + +import com.android.internal.annotations.VisibleForTesting; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.function.Consumer; + +/** + * Collects nodes in the view hierarchy which have been identified as scrollable content. + * + * @hide + */ +@UiThread +public final class ScrollCaptureSearchResults { + private final Executor mExecutor; + private final List mTargets; + private final CancellationSignal mCancel; + + private Runnable mOnCompleteListener; + private int mCompleted; + private boolean mComplete = true; + + public ScrollCaptureSearchResults(Executor executor) { + mExecutor = executor; + mTargets = new ArrayList<>(); + mCancel = new CancellationSignal(); + } + + // Public + + /** + * Add the given target to the results. + * + * @param target the target to consider + */ + public void addTarget(@NonNull ScrollCaptureTarget target) { + requireNonNull(target); + + mTargets.add(target); + mComplete = false; + final ScrollCaptureCallback callback = target.getCallback(); + final Consumer consumer = new SearchRequest(target); + + // Defer so the view hierarchy scan completes first + mExecutor.execute( + () -> callback.onScrollCaptureSearch(mCancel, consumer)); + } + + public boolean isComplete() { + return mComplete; + } + + /** + * Provides a callback to be invoked as soon as all responses have been received from all + * targets to this point. + * + * @param onComplete listener to add + */ + public void setOnCompleteListener(Runnable onComplete) { + if (mComplete) { + onComplete.run(); + } else { + mOnCompleteListener = onComplete; + } + } + + /** + * Indicates whether the search results are empty. + * + * @return true if no targets have been added + */ + public boolean isEmpty() { + return mTargets.isEmpty(); + } + + /** + * Force the results to complete now, cancelling any pending requests and calling a complete + * listener if provided. + */ + public void finish() { + if (!mComplete) { + mCancel.cancel(); + signalComplete(); + } + } + + private void signalComplete() { + mComplete = true; + mTargets.sort(PRIORITY_ORDER); + if (mOnCompleteListener != null) { + mOnCompleteListener.run(); + mOnCompleteListener = null; + } + } + + @VisibleForTesting + public List getTargets() { + return new ArrayList<>(mTargets); + } + + /** + * Get the top ranked result out of all completed requests. + * + * @return the top ranked result + */ + public ScrollCaptureTarget getTopResult() { + ScrollCaptureTarget target = mTargets.isEmpty() ? null : mTargets.get(0); + return target != null && target.getScrollBounds() != null ? target : null; + } + + private class SearchRequest implements Consumer { + private ScrollCaptureTarget mTarget; + + SearchRequest(ScrollCaptureTarget target) { + mTarget = target; + } + + @Override + public void accept(Rect scrollBounds) { + if (mTarget == null || mCancel.isCanceled()) { + return; + } + mExecutor.execute(() -> consume(scrollBounds)); + } + + private void consume(Rect scrollBounds) { + if (mTarget == null || mCancel.isCanceled()) { + return; + } + if (!nullOrEmpty(scrollBounds)) { + mTarget.setScrollBounds(scrollBounds); + mTarget.updatePositionInWindow(); + } + mCompleted++; + mTarget = null; + + // All done? + if (mCompleted == mTargets.size()) { + signalComplete(); + } + } + } + + private static final int AFTER = 1; + private static final int BEFORE = -1; + private static final int EQUAL = 0; + + static final Comparator PRIORITY_ORDER = (a, b) -> { + if (a == null && b == null) { + return 0; + } else if (a == null || b == null) { + return (a == null) ? 1 : -1; + } + + boolean emptyScrollBoundsA = nullOrEmpty(a.getScrollBounds()); + boolean emptyScrollBoundsB = nullOrEmpty(b.getScrollBounds()); + if (emptyScrollBoundsA || emptyScrollBoundsB) { + if (emptyScrollBoundsA && emptyScrollBoundsB) { + return EQUAL; + } + // Prefer the one with a non-empty scroll bounds + if (emptyScrollBoundsA) { + return AFTER; + } + return BEFORE; + } + + final View viewA = a.getContainingView(); + final View viewB = b.getContainingView(); + + // Prefer any view with scrollCaptureHint="INCLUDE", over one without + // This is an escape hatch for the next rule (descendants first) + boolean hintIncludeA = hasIncludeHint(viewA); + boolean hintIncludeB = hasIncludeHint(viewB); + if (hintIncludeA != hintIncludeB) { + return (hintIncludeA) ? BEFORE : AFTER; + } + // If the views are relatives, prefer the descendant. This allows implementations to + // leverage nested scrolling APIs by interacting with the innermost scrollable view (as + // would happen with touch input). + if (isDescendant(viewA, viewB)) { + return BEFORE; + } + if (isDescendant(viewB, viewA)) { + return AFTER; + } + + // finally, prefer one with larger scroll bounds + int scrollAreaA = area(a.getScrollBounds()); + int scrollAreaB = area(b.getScrollBounds()); + return (scrollAreaA >= scrollAreaB) ? BEFORE : AFTER; + }; + + private static int area(Rect r) { + return r.width() * r.height(); + } + + private static boolean nullOrEmpty(Rect r) { + return r == null || r.isEmpty(); + } + + private static boolean hasIncludeHint(View view) { + return (view.getScrollCaptureHint() & View.SCROLL_CAPTURE_HINT_INCLUDE) != 0; + } + + /** + * Determines if {@code otherView} is a descendant of {@code view}. + * + * @param view a view + * @param otherView another view + * @return true if {@code view} is an ancestor of {@code otherView} + */ + private static boolean isDescendant(@NonNull View view, @NonNull View otherView) { + if (view == otherView) { + return false; + } + ViewParent otherParent = otherView.getParent(); + while (otherParent != view && otherParent != null) { + otherParent = otherParent.getParent(); + } + return otherParent == view; + } + + void dump(IndentingPrintWriter writer) { + writer.println("results:"); + writer.increaseIndent(); + writer.println("complete: " + isComplete()); + writer.println("cancelled: " + mCancel.isCanceled()); + writer.println("targets:"); + writer.increaseIndent(); + if (isEmpty()) { + writer.println("None"); + } else { + for (int i = 0; i < mTargets.size(); i++) { + writer.println("[" + i + "]"); + writer.increaseIndent(); + mTargets.get(i).dump(writer); + writer.decreaseIndent(); + } + writer.decreaseIndent(); + } + writer.decreaseIndent(); + } +} diff --git a/core/java/android/view/ScrollCaptureSession.java b/core/java/android/view/ScrollCaptureSession.java index 92617a3252651..748e7ea56f41f 100644 --- a/core/java/android/view/ScrollCaptureSession.java +++ b/core/java/android/view/ScrollCaptureSession.java @@ -16,18 +16,15 @@ package android.view; +import static java.util.Objects.requireNonNull; + import android.annotation.NonNull; -import android.annotation.Nullable; import android.graphics.Point; import android.graphics.Rect; /** * A session represents the scope of interaction between a {@link ScrollCaptureCallback} and the - * system during an active scroll capture operation. During the scope of a session, a callback - * will receive a series of requests for image data. Resources provided here are valid for use - * until {@link ScrollCaptureCallback#onScrollCaptureEnd(Runnable)}. - * - * @hide + * system during an active scroll capture operation. */ public class ScrollCaptureSession { @@ -35,22 +32,27 @@ public class ScrollCaptureSession { private final Rect mScrollBounds; private final Point mPositionInWindow; - @Nullable - private ScrollCaptureConnection mConnection; - - /** @hide */ - public ScrollCaptureSession(Surface surface, Rect scrollBounds, Point positionInWindow, - ScrollCaptureConnection connection) { - mSurface = surface; - mScrollBounds = scrollBounds; - mPositionInWindow = positionInWindow; - mConnection = connection; + /** + * Constructs a new session instance. + * + * @param surface the surface to consume generated images + * @param scrollBounds the bounds of the capture area within the containing view + * @param positionInWindow the offset of scrollBounds within the window + */ + public ScrollCaptureSession(@NonNull Surface surface, @NonNull Rect scrollBounds, + @NonNull Point positionInWindow) { + mSurface = requireNonNull(surface); + mScrollBounds = requireNonNull(scrollBounds); + mPositionInWindow = requireNonNull(positionInWindow); } /** * Returns a * BufferQueue in the * form of a {@link Surface} for transfer of image buffers. + *

+ * The surface is guaranteed to remain {@link Surface#isValid() valid} until the session + * {@link ScrollCaptureCallback#onScrollCaptureEnd(Runnable) ends}. * * @return the surface for transferring image buffers * @throws IllegalStateException if the session has been closed @@ -80,26 +82,4 @@ public class ScrollCaptureSession { public Point getPositionInWindow() { return mPositionInWindow; } - - /** - * Notify the system that an a buffer has been posted via the getSurface() channel. - * - * @param frameNumber the frame number of the queued buffer - * @param capturedArea the area captured, relative to scroll bounds - */ - public void notifyBufferSent(long frameNumber, @NonNull Rect capturedArea) { - if (mConnection != null) { - mConnection.onRequestImageCompleted(frameNumber, capturedArea); - } - } - - /** - * @hide - */ - public void disconnect() { - mConnection = null; - if (mSurface.isValid()) { - mSurface.release(); - } - } } diff --git a/core/java/android/view/ScrollCaptureTarget.java b/core/java/android/view/ScrollCaptureTarget.java index f3fcabb26b315..4fd48892da70e 100644 --- a/core/java/android/view/ScrollCaptureTarget.java +++ b/core/java/android/view/ScrollCaptureTarget.java @@ -22,14 +22,16 @@ import android.annotation.UiThread; import android.graphics.Matrix; import android.graphics.Point; import android.graphics.Rect; +import android.os.CancellationSignal; import com.android.internal.util.FastMath; +import java.io.PrintWriter; +import java.util.function.Consumer; + /** * A target collects the set of contextual information for a ScrollCaptureHandler discovered during * a {@link View#dispatchScrollCaptureSearch scroll capture search}. - * - * @hide */ public final class ScrollCaptureTarget { private final View mContainingView; @@ -41,7 +43,6 @@ public final class ScrollCaptureTarget { private final float[] mTmpFloatArr = new float[2]; private final Matrix mMatrixViewLocalToWindow = new Matrix(); - private final Rect mTmpRect = new Rect(); public ScrollCaptureTarget(@NonNull View scrollTarget, @NonNull Rect localVisibleRect, @NonNull Point positionInWindow, @NonNull ScrollCaptureCallback callback) { @@ -52,7 +53,10 @@ public final class ScrollCaptureTarget { mPositionInWindow = positionInWindow; } - /** @return the hint that the {@code containing view} had during the scroll capture search */ + /** + * @return the hint that the {@code containing view} had during the scroll capture search + * @see View#getScrollCaptureHint() + */ @View.ScrollCaptureHint public int getHint() { return mHint; @@ -71,8 +75,7 @@ public final class ScrollCaptureTarget { } /** - * Returns the un-clipped, visible bounds of the containing view during the scroll capture - * search. This is used to determine on-screen area to assist in selecting the primary target. + * Returns the visible bounds of the containing view. * * @return the visible bounds of the {@code containing view} in view-local coordinates */ @@ -81,13 +84,17 @@ public final class ScrollCaptureTarget { return mLocalVisibleRect; } - /** @return the position of the {@code containing view} within the window */ + /** @return the position of the visible bounds of the containing view within the window */ @NonNull public Point getPositionInWindow() { return mPositionInWindow; } - /** @return the {@code scroll bounds} for this {@link ScrollCaptureCallback callback} */ + /** + * @return the {@code scroll bounds} for this {@link ScrollCaptureCallback callback} + * + * @see ScrollCaptureCallback#onScrollCaptureSearch(CancellationSignal, Consumer) + */ @Nullable public Rect getScrollBounds() { return mScrollBounds; @@ -119,8 +126,8 @@ public final class ScrollCaptureTarget { } /** - * Refresh the value of {@link #mLocalVisibleRect} and {@link #mPositionInWindow} based on the - * current state of the {@code containing view}. + * Refresh the local visible bounds and it's offset within the window, based on the current + * state of the {@code containing view}. */ @UiThread public void updatePositionInWindow() { @@ -132,4 +139,27 @@ public final class ScrollCaptureTarget { roundIntoPoint(mPositionInWindow, mTmpFloatArr); } + public String toString() { + return "ScrollCaptureTarget{" + "view=" + mContainingView + + ", callback=" + mCallback + + ", scrollBounds=" + mScrollBounds + + ", localVisibleRect=" + mLocalVisibleRect + + ", positionInWindow=" + mPositionInWindow + + "}"; + } + + void dump(@NonNull PrintWriter writer) { + View view = getContainingView(); + writer.println("view: " + view); + writer.println("hint: " + mHint); + writer.println("callback: " + mCallback); + writer.println("scrollBounds: " + + (mScrollBounds == null ? "null" : mScrollBounds.toShortString())); + Point inWindow = getPositionInWindow(); + writer.println("positionInWindow: " + + ((inWindow == null) ? "null" : "[" + inWindow.x + "," + inWindow.y + "]")); + Rect localVisible = getLocalVisibleRect(); + writer.println("localVisibleRect: " + + (localVisible == null ? "null" : localVisible.toShortString())); + } } diff --git a/core/java/android/view/ScrollCaptureTargetResolver.java b/core/java/android/view/ScrollCaptureTargetResolver.java deleted file mode 100644 index e4316bbc9397d..0000000000000 --- a/core/java/android/view/ScrollCaptureTargetResolver.java +++ /dev/null @@ -1,337 +0,0 @@ -/* - * 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 android.view; - -import android.annotation.AnyThread; -import android.annotation.NonNull; -import android.annotation.Nullable; -import android.annotation.UiThread; -import android.graphics.Rect; -import android.os.Handler; -import android.os.Looper; -import android.os.SystemClock; -import android.util.Log; - -import com.android.internal.annotations.VisibleForTesting; - - -import java.util.Queue; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; - -/** - * Queries additional state from a list of {@link ScrollCaptureTarget targets} via asynchronous - * callbacks, then aggregates and reduces the target list to a single target, or null if no target - * is suitable. - *

- * The rules for selection are (in order): - *

    - *
  • prefer getScrollBounds(): non-empty - *
  • prefer View.getScrollCaptureHint == SCROLL_CAPTURE_HINT_INCLUDE - *
  • prefer descendants before parents - *
  • prefer larger area for getScrollBounds() (clipped to view bounds) - *
- * - *

- * All calls to {@link ScrollCaptureCallback#onScrollCaptureSearch} are made on the main thread, - * with results are queued and consumed to the main thread as well. - * - * @see #start(Handler, long, Consumer) - * - * @hide - */ -@UiThread -public class ScrollCaptureTargetResolver { - private static final String TAG = "ScrollCaptureTargetRes"; - - private final Object mLock = new Object(); - - private final Queue mTargets; - private Handler mHandler; - private long mTimeLimitMillis; - - private Consumer mWhenComplete; - private int mPendingBoundsRequests; - private long mDeadlineMillis; - - private ScrollCaptureTarget mResult; - private boolean mFinished; - - private boolean mStarted; - - private static int area(Rect r) { - return r.width() * r.height(); - } - - private static boolean nullOrEmpty(Rect r) { - return r == null || r.isEmpty(); - } - - /** - * Binary operator which selects the best {@link ScrollCaptureTarget}. - */ - private static ScrollCaptureTarget chooseTarget(ScrollCaptureTarget a, ScrollCaptureTarget b) { - if (a == null && b == null) { - return null; - } else if (a == null || b == null) { - ScrollCaptureTarget c = (a == null) ? b : a; - return c; - } - - boolean emptyScrollBoundsA = nullOrEmpty(a.getScrollBounds()); - boolean emptyScrollBoundsB = nullOrEmpty(b.getScrollBounds()); - if (emptyScrollBoundsA || emptyScrollBoundsB) { - if (emptyScrollBoundsA && emptyScrollBoundsB) { - // Both have an empty or null scrollBounds - Log.d(TAG, "chooseTarget: (both have empty or null bounds) return " + null); - return null; - } - // Prefer the one with a non-empty scroll bounds - if (emptyScrollBoundsA) { - Log.d(TAG, "chooseTarget: (a has empty or null bounds) return " + b); - return b; - } - Log.d(TAG, "chooseTarget: (b has empty or null bounds) return " + a); - return a; - } - - final View viewA = a.getContainingView(); - final View viewB = b.getContainingView(); - - // Prefer any view with scrollCaptureHint="INCLUDE", over one without - // This is an escape hatch for the next rule (descendants first) - boolean hintIncludeA = hasIncludeHint(viewA); - boolean hintIncludeB = hasIncludeHint(viewB); - if (hintIncludeA != hintIncludeB) { - ScrollCaptureTarget c = (hintIncludeA) ? a : b; - Log.d(TAG, "chooseTarget: (has hint=INCLUDE) return " + c); - return c; - } - - // If the views are relatives, prefer the descendant. This allows implementations to - // leverage nested scrolling APIs by interacting with the innermost scrollable view (as - // would happen with touch input). - if (isDescendant(viewA, viewB)) { - Log.d(TAG, "chooseTarget: (b is descendant of a) return " + b); - return b; - } - if (isDescendant(viewB, viewA)) { - Log.d(TAG, "chooseTarget: (a is descendant of b) return " + a); - return a; - } - - // finally, prefer one with larger scroll bounds - int scrollAreaA = area(a.getScrollBounds()); - int scrollAreaB = area(b.getScrollBounds()); - ScrollCaptureTarget c = (scrollAreaA >= scrollAreaB) ? a : b; - Log.d(TAG, "chooseTarget: return " + c); - return c; - } - - /** - * Creates an instance to query and filter {@code target}. - * - * @param targets a list of {@link ScrollCaptureTarget} as collected by {@link - * View#dispatchScrollCaptureSearch}. - * @see #start(Handler, long, Consumer) - */ - public ScrollCaptureTargetResolver(Queue targets) { - mTargets = targets; - } - - void checkThread() { - if (mHandler.getLooper() != Looper.myLooper()) { - throw new IllegalStateException("Called from wrong thread! (" - + Thread.currentThread().getName() + ")"); - } - } - - /** - * Blocks until a result is returned (after completion or timeout). - *

- * For testing only. Normal usage should receive a callback after calling {@link #start}. - */ - @VisibleForTesting - public ScrollCaptureTarget waitForResult() throws InterruptedException { - synchronized (mLock) { - while (!mFinished) { - mLock.wait(); - } - } - return mResult; - } - - private void supplyResult(ScrollCaptureTarget target) { - checkThread(); - if (mFinished) { - return; - } - mResult = chooseTarget(mResult, target); - boolean finish = mPendingBoundsRequests == 0 - || SystemClock.uptimeMillis() >= mDeadlineMillis; - if (finish) { - mPendingBoundsRequests = 0; - mWhenComplete.accept(mResult); - synchronized (mLock) { - mFinished = true; - mLock.notify(); - } - mWhenComplete = null; - } - } - - /** - * Asks all targets for {@link ScrollCaptureCallback#onScrollCaptureSearch(Consumer) - * scrollBounds}, and selects the primary target according to the {@link - * #chooseTarget} function. - * - * @param timeLimitMillis the amount of time to wait for all responses before delivering the top - * result - * @param resultConsumer the consumer to receive the primary target - */ - @AnyThread - public void start(Handler uiHandler, long timeLimitMillis, - Consumer resultConsumer) { - synchronized (mLock) { - if (mStarted) { - throw new IllegalStateException("already started!"); - } - if (timeLimitMillis < 0) { - throw new IllegalArgumentException("Time limit must be positive"); - } - mHandler = uiHandler; - mTimeLimitMillis = timeLimitMillis; - mWhenComplete = resultConsumer; - if (mTargets.isEmpty()) { - mHandler.post(() -> supplyResult(null)); - return; - } - mStarted = true; - uiHandler.post(this::run); - } - } - - private void run() { - checkThread(); - - mPendingBoundsRequests = mTargets.size(); - for (ScrollCaptureTarget target : mTargets) { - queryTarget(target); - } - mDeadlineMillis = SystemClock.uptimeMillis() + mTimeLimitMillis; - mHandler.postAtTime(mTimeoutRunnable, mDeadlineMillis); - } - - private final Runnable mTimeoutRunnable = () -> { - checkThread(); - supplyResult(null); - }; - - /** - * Adds a target to the list and requests {@link ScrollCaptureCallback#onScrollCaptureSearch} - * scrollBounds} from it. Results are returned by a call to {@link #onScrollBoundsProvided}. - * - * @param target the target to add - */ - @UiThread - private void queryTarget(@NonNull ScrollCaptureTarget target) { - checkThread(); - final ScrollCaptureCallback callback = target.getCallback(); - // from the UI thread, request scroll bounds - callback.onScrollCaptureSearch( - // allow only one callback to onReady.accept(): - new SingletonConsumer( - // Queue and consume on the UI thread - ((scrollBounds) -> mHandler.post( - () -> onScrollBoundsProvided(target, scrollBounds))))); - } - - @UiThread - private void onScrollBoundsProvided(ScrollCaptureTarget target, @Nullable Rect scrollBounds) { - checkThread(); - if (mFinished) { - return; - } - - // Record progress. - mPendingBoundsRequests--; - - // Remove the timeout. - mHandler.removeCallbacks(mTimeoutRunnable); - - boolean doneOrTimedOut = mPendingBoundsRequests == 0 - || SystemClock.uptimeMillis() >= mDeadlineMillis; - - final View containingView = target.getContainingView(); - if (!nullOrEmpty(scrollBounds) && containingView.isAggregatedVisible()) { - target.updatePositionInWindow(); - target.setScrollBounds(scrollBounds); - supplyResult(target); - } - - if (!mFinished) { - // Reschedule the timeout. - mHandler.postAtTime(mTimeoutRunnable, mDeadlineMillis); - } - } - - private static boolean hasIncludeHint(View view) { - return (view.getScrollCaptureHint() & View.SCROLL_CAPTURE_HINT_INCLUDE) != 0; - } - - /** - * Determines if {@code otherView} is a descendant of {@code view}. - * - * @param view a view - * @param otherView another view - * @return true if {@code view} is an ancestor of {@code otherView} - */ - private static boolean isDescendant(@NonNull View view, @NonNull View otherView) { - if (view == otherView) { - return false; - } - ViewParent otherParent = otherView.getParent(); - while (otherParent != view && otherParent != null) { - otherParent = otherParent.getParent(); - } - return otherParent == view; - } - - /** - * A safe wrapper for a consumer callbacks intended to accept a single value. It ensures - * that the receiver of the consumer does not retain a reference to {@code target} after use nor - * cause race conditions by invoking {@link Consumer#accept accept} more than once. - */ - static class SingletonConsumer implements Consumer { - final AtomicReference> mAtomicRef; - - /** - * @param target the target consumer - **/ - SingletonConsumer(Consumer target) { - mAtomicRef = new AtomicReference<>(target); - } - - @Override - public void accept(T t) { - final Consumer consumer = mAtomicRef.getAndSet(null); - if (consumer != null) { - consumer.accept(t); - } - } - } -} diff --git a/core/java/android/view/View.java b/core/java/android/view/View.java index 3789324a038c8..ebef4646b0d9b 100644 --- a/core/java/android/view/View.java +++ b/core/java/android/view/View.java @@ -184,10 +184,10 @@ import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Queue; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.Executor; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; import java.util.function.Predicate; /** @@ -1484,7 +1484,6 @@ public class View implements Drawable.Callback, KeyEvent.Callback, * * @see #getScrollCaptureHint() * @see #setScrollCaptureHint(int) - * @hide */ public static final int SCROLL_CAPTURE_HINT_AUTO = 0; @@ -1495,7 +1494,6 @@ public class View implements Drawable.Callback, KeyEvent.Callback, * * @see #getScrollCaptureHint() * @see #setScrollCaptureHint(int) - * @hide */ public static final int SCROLL_CAPTURE_HINT_EXCLUDE = 0x1; @@ -1506,7 +1504,6 @@ public class View implements Drawable.Callback, KeyEvent.Callback, * * @see #getScrollCaptureHint() * @see #setScrollCaptureHint(int) - * @hide */ public static final int SCROLL_CAPTURE_HINT_INCLUDE = 0x2; @@ -1517,7 +1514,6 @@ public class View implements Drawable.Callback, KeyEvent.Callback, * * @see #getScrollCaptureHint() * @see #setScrollCaptureHint(int) - * @hide */ public static final int SCROLL_CAPTURE_HINT_EXCLUDE_DESCENDANTS = 0x4; @@ -30053,8 +30049,6 @@ public class View implements Drawable.Callback, KeyEvent.Callback, * Returns the current scroll capture hint for this view. * * @return the current scroll capture hint - * - * @hide */ @ScrollCaptureHint public int getScrollCaptureHint() { @@ -30067,8 +30061,6 @@ public class View implements Drawable.Callback, KeyEvent.Callback, * scroll capture targets. * * @param hint the scrollCaptureHint flags value to set - * - * @hide */ public void setScrollCaptureHint(@ScrollCaptureHint int hint) { mPrivateFlags4 &= ~PFLAG4_SCROLL_CAPTURE_HINT_MASK; @@ -30088,10 +30080,8 @@ public class View implements Drawable.Callback, KeyEvent.Callback, * setting a custom callback to help ensure it is selected as the target. * * @param callback the new callback to assign - * - * @hide */ - public void setScrollCaptureCallback(@Nullable ScrollCaptureCallback callback) { + public final void setScrollCaptureCallback(@Nullable ScrollCaptureCallback callback) { getListenerInfo().mScrollCaptureCallback = callback; } @@ -30109,6 +30099,22 @@ public class View implements Drawable.Callback, KeyEvent.Callback, windowOffset); } + /** + * Dispatch a scroll capture search request down the view hierarchy. + * + * @param localVisibleRect the visible area of this ViewGroup in local coordinates, according to + * the parent + * @param windowOffset the offset of this view within the window + * @param targets accepts potential scroll capture targets; {@link Consumer#accept + * results.accept} may be called zero or more times on the calling + * thread before onScrollCaptureSearch returns + */ + public void dispatchScrollCaptureSearch( + @NonNull Rect localVisibleRect, @NonNull Point windowOffset, + @NonNull Consumer targets) { + onScrollCaptureSearch(localVisibleRect, windowOffset, targets); + } + /** * Called when scroll capture is requested, to search for appropriate content to scroll. If * applicable, this view adds itself to the provided list for consideration, subject to the @@ -30116,23 +30122,32 @@ public class View implements Drawable.Callback, KeyEvent.Callback, * * @param localVisibleRect the local visible rect of this view * @param windowOffset the offset of localVisibleRect within the window - * @param targets a queue which collects potential targets - * + * @param targets accepts potential scroll capture targets; {@link Consumer#accept + * results.accept} may be called zero or more times on the calling + * thread before onScrollCaptureSearch returns * @throws IllegalStateException if this view is not attached to a window - * @hide */ - public void dispatchScrollCaptureSearch(@NonNull Rect localVisibleRect, - @NonNull Point windowOffset, @NonNull Queue targets) { + public void onScrollCaptureSearch(@NonNull Rect localVisibleRect, + @NonNull Point windowOffset, @NonNull Consumer targets) { int hint = getScrollCaptureHint(); if ((hint & SCROLL_CAPTURE_HINT_EXCLUDE) != 0) { return; } + boolean rectIsVisible = true; + + // Apply clipBounds if present. + if (mClipBounds != null) { + rectIsVisible = localVisibleRect.intersect(mClipBounds); + } + if (!rectIsVisible) { + return; + } // Get a callback provided by the framework, library or application. ScrollCaptureCallback callback = (mListenerInfo == null) ? null : mListenerInfo.mScrollCaptureCallback; - // Try internal support for standard scrolling containers. + // Try framework support for standard scrolling containers. if (callback == null) { callback = createScrollCaptureCallbackInternal(localVisibleRect, windowOffset); } @@ -30142,7 +30157,7 @@ public class View implements Drawable.Callback, KeyEvent.Callback, // Add to the list for consideration Point offset = new Point(windowOffset.x, windowOffset.y); Rect rect = new Rect(localVisibleRect); - targets.add(new ScrollCaptureTarget(this, rect, offset, callback)); + targets.accept(new ScrollCaptureTarget(this, rect, offset, callback)); } } diff --git a/core/java/android/view/ViewGroup.java b/core/java/android/view/ViewGroup.java index 37bea5821e42b..38a59373554c7 100644 --- a/core/java/android/view/ViewGroup.java +++ b/core/java/android/view/ViewGroup.java @@ -77,7 +77,7 @@ import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Queue; +import java.util.function.Consumer; import java.util.function.Predicate; /** @@ -7462,62 +7462,6 @@ public abstract class ViewGroup extends View implements ViewParent, ViewManager } } - /** - * Offsets the given rectangle in parent's local coordinates into child's coordinate space - * and clips the result to the child View's bounds, padding and clipRect if appropriate. If the - * resulting rectangle is not empty, the request is forwarded to the child. - *

- * Note: This method does not account for any static View transformations which may be - * applied to the child view. - * - * @param child the child to dispatch to - * @param localVisibleRect the visible (clipped) area of this ViewGroup, in local coordinates - * @param windowOffset the offset of localVisibleRect within the window - * @param targets a queue to collect located targets - */ - private void dispatchTransformedScrollCaptureSearch(View child, Rect localVisibleRect, - Point windowOffset, Queue targets) { - - // copy local visible rect for modification and dispatch - final Rect childVisibleRect = getTempRect(); - childVisibleRect.set(localVisibleRect); - - // transform to child coords - final Point childWindowOffset = getTempPoint(); - childWindowOffset.set(windowOffset.x, windowOffset.y); - - final int dx = child.mLeft - mScrollX; - final int dy = child.mTop - mScrollY; - - childVisibleRect.offset(-dx, -dy); - childWindowOffset.offset(dx, dy); - - boolean rectIsVisible = true; - final int width = mRight - mLeft; - final int height = mBottom - mTop; - - // Clip to child bounds - if (getClipChildren()) { - rectIsVisible = childVisibleRect.intersect(0, 0, child.getWidth(), child.getHeight()); - } - - // Clip to child padding. - if (rectIsVisible && (child instanceof ViewGroup) - && ((ViewGroup) child).getClipToPadding()) { - rectIsVisible = childVisibleRect.intersect( - child.mPaddingLeft, child.mPaddingTop, - child.getWidth() - child.mPaddingRight, - child.getHeight() - child.mPaddingBottom); - } - // Clip to child clipBounds. - if (rectIsVisible && child.mClipBounds != null) { - rectIsVisible = childVisibleRect.intersect(child.mClipBounds); - } - if (rectIsVisible) { - child.dispatchScrollCaptureSearch(childVisibleRect, childWindowOffset, targets); - } - } - /** * Handle the scroll capture search request by checking this view if applicable, then to each * child view. @@ -7525,30 +7469,67 @@ public abstract class ViewGroup extends View implements ViewParent, ViewManager * @param localVisibleRect the visible area of this ViewGroup in local coordinates, according to * the parent * @param windowOffset the offset of this view within the window - * @param targets the collected list of scroll capture targets - * - * @hide + * @param targets accepts potential scroll capture targets; {@link Consumer#accept + * results.accept} may be called zero or more times on the calling + * thread before onScrollCaptureSearch returns */ @Override public void dispatchScrollCaptureSearch( @NonNull Rect localVisibleRect, @NonNull Point windowOffset, - @NonNull Queue targets) { + @NonNull Consumer targets) { + + // copy local visible rect for modification and dispatch + final Rect rect = getTempRect(); + rect.set(localVisibleRect); + + if (getClipToPadding()) { + rect.inset(mPaddingLeft, mPaddingTop, mPaddingRight, mPaddingBottom); + } // Dispatch to self first. super.dispatchScrollCaptureSearch(localVisibleRect, windowOffset, targets); - // Then dispatch to children, if not excluding descendants. - if ((getScrollCaptureHint() & SCROLL_CAPTURE_HINT_EXCLUDE_DESCENDANTS) == 0) { - final int childCount = getChildCount(); - for (int i = 0; i < childCount; i++) { - View child = getChildAt(i); - // Only visible views can be captured. - if (child.getVisibility() != View.VISIBLE) { - continue; - } - // Transform to child coords and dispatch - dispatchTransformedScrollCaptureSearch(child, localVisibleRect, windowOffset, - targets); + // Skip children if descendants excluded. + if ((getScrollCaptureHint() & SCROLL_CAPTURE_HINT_EXCLUDE_DESCENDANTS) != 0) { + return; + } + + final int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + View child = getChildAt(i); + // Only visible views can be captured. + if (child.getVisibility() != View.VISIBLE) { + continue; + } + // Offset the given rectangle (in parent's local coordinates) into child's coordinate + // space and clip the result to the child View's bounds, padding and clipRect as needed. + // If the resulting rectangle is not empty, the request is forwarded to the child. + + // copy local visible rect for modification and dispatch + final Rect childVisibleRect = getTempRect(); + childVisibleRect.set(localVisibleRect); + + // transform to child coords + final Point childWindowOffset = getTempPoint(); + childWindowOffset.set(windowOffset.x, windowOffset.y); + + final int dx = child.mLeft - mScrollX; + final int dy = child.mTop - mScrollY; + + childVisibleRect.offset(-dx, -dy); + childWindowOffset.offset(dx, dy); + + boolean rectIsVisible = true; + + // Clip to child bounds + if (getClipChildren()) { + rectIsVisible = childVisibleRect.intersect(0, 0, child.getWidth(), + child.getHeight()); + } + + // Clip to child padding. + if (rectIsVisible) { + child.dispatchScrollCaptureSearch(childVisibleRect, childWindowOffset, targets); } } } diff --git a/core/java/android/view/ViewRootImpl.java b/core/java/android/view/ViewRootImpl.java index 1abcb15ca7a36..4f6679b2bc972 100644 --- a/core/java/android/view/ViewRootImpl.java +++ b/core/java/android/view/ViewRootImpl.java @@ -141,6 +141,7 @@ import android.util.AndroidRuntimeException; import android.util.ArraySet; import android.util.DisplayMetrics; import android.util.EventLog; +import android.util.IndentingPrintWriter; import android.util.Log; import android.util.LongArray; import android.util.MergedConfiguration; @@ -202,6 +203,7 @@ import com.android.internal.view.SurfaceCallbackHelper; import java.io.IOException; import java.io.OutputStream; import java.io.PrintWriter; +import java.io.StringWriter; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.HashSet; @@ -274,6 +276,11 @@ public final class ViewRootImpl implements ViewParent, */ private static final int CONTENT_CAPTURE_ENABLED_FALSE = 2; + /** + * Maximum time to wait for {@link View#dispatchScrollCaptureSearch} to complete. + */ + private static final int SCROLL_CAPTURE_REQUEST_TIMEOUT_MILLIS = 2500; + @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553) static final ThreadLocal sRunQueues = new ThreadLocal(); @@ -668,8 +675,6 @@ public final class ViewRootImpl implements ViewParent, private final InsetsController mInsetsController; private final ImeFocusController mImeFocusController; - private ScrollCaptureConnection mScrollCaptureConnection; - private boolean mIsSurfaceOpaque; private final BackgroundBlurDrawable.Aggregator mBlurRegionAggregator = @@ -683,12 +688,6 @@ public final class ViewRootImpl implements ViewParent, return mImeFocusController; } - /** @return The current {@link ScrollCaptureConnection} for this instance, if any is active. */ - @Nullable - public ScrollCaptureConnection getScrollCaptureConnection() { - return mScrollCaptureConnection; - } - private final GestureExclusionTracker mGestureExclusionTracker = new GestureExclusionTracker(); private IAccessibilityEmbeddedConnection mAccessibilityEmbeddedConnection; @@ -730,6 +729,8 @@ public final class ViewRootImpl implements ViewParent, private HashSet mRootScrollCaptureCallbacks; + private long mScrollCaptureRequestTimeout = SCROLL_CAPTURE_REQUEST_TIMEOUT_MILLIS; + /** * Increment this value when the surface has been replaced. */ @@ -817,6 +818,8 @@ public final class ViewRootImpl implements ViewParent, mImeFocusController = new ImeFocusController(this); AudioManager audioManager = mContext.getSystemService(AudioManager.class); mFastScrollSoundEffectsEnabled = audioManager.areNavigationRepeatSoundEffectsEnabled(); + + mScrollCaptureRequestTimeout = SCROLL_CAPTURE_REQUEST_TIMEOUT_MILLIS; } public static void addFirstDrawHandler(Runnable callback) { @@ -9223,9 +9226,9 @@ public final class ViewRootImpl implements ViewParent, * Collect and include any ScrollCaptureCallback instances registered with the window. * * @see #addScrollCaptureCallback(ScrollCaptureCallback) - * @param targets the search queue for targets + * @param results an object to collect the results of the search */ - private void collectRootScrollCaptureTargets(Queue targets) { + private void collectRootScrollCaptureTargets(ScrollCaptureSearchResults results) { if (mRootScrollCaptureCallbacks == null) { return; } @@ -9233,26 +9236,45 @@ public final class ViewRootImpl implements ViewParent, // Add to the list for consideration Point offset = new Point(mView.getLeft(), mView.getTop()); Rect rect = new Rect(0, 0, mView.getWidth(), mView.getHeight()); - targets.add(new ScrollCaptureTarget(mView, rect, offset, cb)); + results.addTarget(new ScrollCaptureTarget(mView, rect, offset, cb)); } } /** - * Handles an inbound request for scroll capture from the system. If a client is not already - * active, a search will be dispatched through the view tree to locate scrolling content. + * Update the timeout for scroll capture requests. Only affects this view root. + * The default value is {@link #SCROLL_CAPTURE_REQUEST_TIMEOUT_MILLIS}. + * + * @param timeMillis the new timeout in milliseconds + */ + public void setScrollCaptureRequestTimeout(int timeMillis) { + mScrollCaptureRequestTimeout = timeMillis; + } + + /** + * Get the current timeout for scroll capture requests. + * + * @return the timeout in milliseconds + */ + public long getScrollCaptureRequestTimeout() { + return mScrollCaptureRequestTimeout; + } + + /** + * Handles an inbound request for scroll capture from the system. A search will be + * dispatched through the view tree to locate scrolling content. *

- * Either {@link IScrollCaptureCallbacks#onConnected(IScrollCaptureConnection, Rect, - * Point)} or {@link IScrollCaptureCallbacks#onUnavailable()} will be returned - * depending on the results of the search. + * A call to {@link IScrollCaptureCallbacks#onScrollCaptureResponse(ScrollCaptureResponse)} + * will follow. * * @param callbacks to receive responses - * @see ScrollCaptureTargetResolver + * @see ScrollCaptureTargetSelector */ public void handleScrollCaptureRequest(@NonNull IScrollCaptureCallbacks callbacks) { - LinkedList targetList = new LinkedList<>(); + ScrollCaptureSearchResults results = + new ScrollCaptureSearchResults(mContext.getMainExecutor()); // Window (root) level callbacks - collectRootScrollCaptureTargets(targetList); + collectRootScrollCaptureTargets(results); // Search through View-tree View rootView = getView(); @@ -9260,58 +9282,70 @@ public final class ViewRootImpl implements ViewParent, Point point = new Point(); Rect rect = new Rect(0, 0, rootView.getWidth(), rootView.getHeight()); getChildVisibleRect(rootView, rect, point); - rootView.dispatchScrollCaptureSearch(rect, point, targetList); + rootView.dispatchScrollCaptureSearch(rect, point, results::addTarget); } - - // No-op path. Scroll capture not offered for this window. - if (targetList.isEmpty()) { - dispatchScrollCaptureSearchResult(callbacks, null); - return; + Runnable onComplete = () -> dispatchScrollCaptureSearchResult(callbacks, results); + results.setOnCompleteListener(onComplete); + if (!results.isComplete()) { + mHandler.postDelayed(results::finish, getScrollCaptureRequestTimeout()); } - - // Request scrollBounds from each of the targets. - // Continues with the consumer once all responses are consumed, or the timeout expires. - ScrollCaptureTargetResolver resolver = new ScrollCaptureTargetResolver(targetList); - resolver.start(mHandler, 1000, - (selected) -> dispatchScrollCaptureSearchResult(callbacks, selected)); } /** Called by {@link #handleScrollCaptureRequest} when a result is returned */ private void dispatchScrollCaptureSearchResult( @NonNull IScrollCaptureCallbacks callbacks, - @Nullable ScrollCaptureTarget selectedTarget) { + @NonNull ScrollCaptureSearchResults results) { + + ScrollCaptureTarget selectedTarget = results.getTopResult(); + + ScrollCaptureResponse.Builder response = new ScrollCaptureResponse.Builder(); + response.setWindowTitle(getTitle().toString()); + + StringWriter writer = new StringWriter(); + IndentingPrintWriter pw = new IndentingPrintWriter(writer); + results.dump(pw); + pw.flush(); + response.addMessage(writer.toString()); - // If timeout or no eligible targets found. if (selectedTarget == null) { + response.setDescription("No scrollable targets found in window"); try { - if (DEBUG_SCROLL_CAPTURE) { - Log.d(TAG, "scrollCaptureSearch returned no targets available."); - } - callbacks.onUnavailable(); + callbacks.onScrollCaptureResponse(response.build()); } catch (RemoteException e) { - if (DEBUG_SCROLL_CAPTURE) { - Log.w(TAG, "Failed to send scroll capture search result.", e); - } + Log.e(TAG, "Failed to send scroll capture search result", e); } return; } - // Create a client instance and return it to the caller - mScrollCaptureConnection = new ScrollCaptureConnection(selectedTarget, callbacks); + response.setDescription("Connected"); + + // Compute area covered by scrolling content within window + Rect boundsInWindow = new Rect(); + View containingView = selectedTarget.getContainingView(); + containingView.getLocationInWindow(mAttachInfo.mTmpLocation); + boundsInWindow.set(selectedTarget.getScrollBounds()); + boundsInWindow.offset(mAttachInfo.mTmpLocation[0], mAttachInfo.mTmpLocation[1]); + response.setBoundsInWindow(boundsInWindow); + + // Compute the area on screen covered by the window + Rect boundsOnScreen = new Rect(); + mView.getLocationOnScreen(mAttachInfo.mTmpLocation); + boundsOnScreen.set(0, 0, mView.getWidth(), mView.getHeight()); + boundsOnScreen.offset(mAttachInfo.mTmpLocation[0], mAttachInfo.mTmpLocation[1]); + response.setWindowBounds(boundsOnScreen); + + // Create a connection and return it to the caller + ScrollCaptureConnection connection = new ScrollCaptureConnection( + mView.getContext().getMainExecutor(), selectedTarget, callbacks); + response.setConnection(connection); + try { - if (DEBUG_SCROLL_CAPTURE) { - Log.d(TAG, "scrollCaptureSearch returning client: " + getScrollCaptureConnection()); - } - callbacks.onConnected( - mScrollCaptureConnection, - selectedTarget.getScrollBounds(), - selectedTarget.getPositionInWindow()); + callbacks.onScrollCaptureResponse(response.build()); } catch (RemoteException e) { if (DEBUG_SCROLL_CAPTURE) { - Log.w(TAG, "Failed to send scroll capture search result.", e); + Log.w(TAG, "Failed to send scroll capture search response.", e); } - mScrollCaptureConnection.disconnect(); - mScrollCaptureConnection = null; + connection.close(); } } diff --git a/core/java/android/view/Window.java b/core/java/android/view/Window.java index 4ecdd78f5a429..221b3346df58f 100644 --- a/core/java/android/view/Window.java +++ b/core/java/android/view/Window.java @@ -2621,7 +2621,6 @@ public abstract class Window { * callback with the root view of the window. * * @param callback the callback to add - * @hide */ public void registerScrollCaptureCallback(@NonNull ScrollCaptureCallback callback) { } @@ -2630,7 +2629,6 @@ public abstract class Window { * Unregisters a {@link ScrollCaptureCallback} previously registered with this window. * * @param callback the callback to remove - * @hide */ public void unregisterScrollCaptureCallback(@NonNull ScrollCaptureCallback callback) { } diff --git a/core/java/com/android/internal/view/BaseIWindow.java b/core/java/com/android/internal/view/BaseIWindow.java index ef2275d4218c5..ab0149fce0a0b 100644 --- a/core/java/com/android/internal/view/BaseIWindow.java +++ b/core/java/com/android/internal/view/BaseIWindow.java @@ -30,6 +30,7 @@ import android.view.IWindowSession; import android.view.InsetsSourceControl; import android.view.InsetsState; import android.view.PointerIcon; +import android.view.ScrollCaptureResponse; import android.view.WindowInsets.Type.InsetsType; import android.window.ClientWindowFrames; @@ -160,7 +161,9 @@ public class BaseIWindow extends IWindow.Stub { @Override public void requestScrollCapture(IScrollCaptureCallbacks callbacks) { try { - callbacks.onUnavailable(); + callbacks.onScrollCaptureResponse( + new ScrollCaptureResponse.Builder().setDescription("Not Implemented").build()); + } catch (RemoteException ex) { // ignore } diff --git a/core/java/com/android/internal/view/ScrollCaptureViewSupport.java b/core/java/com/android/internal/view/ScrollCaptureViewSupport.java index 85fa791b429c4..a41511b74a7d6 100644 --- a/core/java/com/android/internal/view/ScrollCaptureViewSupport.java +++ b/core/java/com/android/internal/view/ScrollCaptureViewSupport.java @@ -16,15 +16,19 @@ package com.android.internal.view; +import android.annotation.UiThread; +import android.content.Context; +import android.content.pm.ActivityInfo; import android.graphics.HardwareRenderer; import android.graphics.Matrix; import android.graphics.RecordingCanvas; import android.graphics.Rect; import android.graphics.RectF; import android.graphics.RenderNode; -import android.os.Handler; +import android.os.CancellationSignal; import android.util.DisplayMetrics; import android.util.Log; +import android.view.Display.ColorMode; import android.view.ScrollCaptureCallback; import android.view.ScrollCaptureSession; import android.view.Surface; @@ -44,32 +48,42 @@ import java.util.function.Consumer; * @param the specific View subclass handled * @see ScrollCaptureViewHelper */ +@UiThread public class ScrollCaptureViewSupport implements ScrollCaptureCallback { - public static final long NO_FRAME_PRODUCED = -1; - private static final String TAG = "ScrollCaptureViewSupport"; private static final boolean WAIT_FOR_ANIMATION = true; private final WeakReference mWeakView; private final ScrollCaptureViewHelper mViewHelper; - private ViewRenderer mRenderer; - private Handler mUiHandler; + private final ViewRenderer mRenderer; private boolean mStarted; private boolean mEnded; ScrollCaptureViewSupport(V containingView, ScrollCaptureViewHelper viewHelper) { mWeakView = new WeakReference<>(containingView); mRenderer = new ViewRenderer(); - mUiHandler = containingView.getHandler(); + // TODO(b/177649144): provide access to color space from android.media.Image mViewHelper = viewHelper; } - // Base implementation of ScrollCaptureCallback + /** Based on ViewRootImpl#updateColorModeIfNeeded */ + @ColorMode + private static int getColorMode(View containingView) { + Context context = containingView.getContext(); + int colorMode = containingView.getViewRootImpl().mWindowAttributes.getColorMode(); + if (!context.getResources().getConfiguration().isScreenWideColorGamut()) { + colorMode = ActivityInfo.COLOR_MODE_DEFAULT; + } + return colorMode; + } @Override - public final void onScrollCaptureSearch(Consumer onReady) { + public final void onScrollCaptureSearch(CancellationSignal signal, Consumer onReady) { + if (signal.isCanceled()) { + return; + } V view = mWeakView.get(); mStarted = false; mEnded = false; @@ -82,7 +96,11 @@ public class ScrollCaptureViewSupport implements ScrollCaptureCa } @Override - public final void onScrollCaptureStart(ScrollCaptureSession session, Runnable onReady) { + public final void onScrollCaptureStart(ScrollCaptureSession session, CancellationSignal signal, + Runnable onReady) { + if (signal.isCanceled()) { + return; + } V view = mWeakView.get(); mEnded = false; @@ -99,18 +117,22 @@ public class ScrollCaptureViewSupport implements ScrollCaptureCa } @Override - public final void onScrollCaptureImageRequest(ScrollCaptureSession session, Rect requestRect) { + public final void onScrollCaptureImageRequest(ScrollCaptureSession session, + CancellationSignal signal, Rect requestRect, Consumer onComplete) { + if (signal.isCanceled()) { + return; + } V view = mWeakView.get(); if (view == null || !view.isVisibleToUser()) { // Signal to the controller that we have a problem and can't continue. - session.notifyBufferSent(NO_FRAME_PRODUCED, new Rect()); + onComplete.accept(new Rect()); return; } // Ask the view to scroll as needed to bring this area into view. ScrollResult scrollResult = mViewHelper.onScrollRequested(view, session.getScrollBounds(), requestRect); if (scrollResult.availableArea.isEmpty()) { - session.notifyBufferSent(NO_FRAME_PRODUCED, scrollResult.availableArea); + onComplete.accept(scrollResult.availableArea); return; } view.invalidate(); // don't wait for vsync @@ -121,17 +143,13 @@ public class ScrollCaptureViewSupport implements ScrollCaptureCa viewCaptureArea.offset(0, -scrollResult.scrollDelta); if (WAIT_FOR_ANIMATION) { - Log.d(TAG, "render: delaying until animation"); view.postOnAnimation(() -> { - Log.d(TAG, "postOnAnimation(): rendering now"); - long resultFrame = mRenderer.renderView(view, viewCaptureArea); - Log.d(TAG, "notifyBufferSent: " + scrollResult.availableArea); - - session.notifyBufferSent(resultFrame, new Rect(scrollResult.availableArea)); + mRenderer.renderView(view, viewCaptureArea); + onComplete.accept(new Rect(scrollResult.availableArea)); }); } else { - long resultFrame = mRenderer.renderView(view, viewCaptureArea); - session.notifyBufferSent(resultFrame, new Rect(scrollResult.availableArea)); + mRenderer.renderView(view, viewCaptureArea); + onComplete.accept(new Rect(scrollResult.availableArea)); } } @@ -239,7 +257,7 @@ public class ScrollCaptureViewSupport implements ScrollCaptureCa mCaptureRenderNode.endRecording(); } - public long renderView(View view, Rect sourceRect) { + public void renderView(View view, Rect sourceRect) { if (updateForView(view)) { setupLighting(view); } @@ -258,7 +276,7 @@ public class ScrollCaptureViewSupport implements ScrollCaptureCa switch (request.syncAndDraw()) { case HardwareRenderer.SYNC_OK: case HardwareRenderer.SYNC_REDRAW_REQUESTED: - return frameNumber; + return; case HardwareRenderer.SYNC_FRAME_DROPPED: Log.e(TAG, "syncAndDraw(): SYNC_FRAME_DROPPED !"); @@ -270,7 +288,6 @@ public class ScrollCaptureViewSupport implements ScrollCaptureCa Log.e(TAG, "syncAndDraw(): SYNC_CONTEXT_IS_STOPPED !"); break; } - return NO_FRAME_PRODUCED; } public void trimMemory() { @@ -289,5 +306,17 @@ public class ScrollCaptureViewSupport implements ScrollCaptureCa mTempMatrix.mapRect(mTempRectF); mTempRectF.round(outRect); } + + public void setColorMode(@ColorMode int colorMode) { + mRenderer.setColorMode(colorMode); + } + } + + @Override + public String toString() { + return "ScrollCaptureViewSupport{" + + "view=" + mWeakView.get() + + ", helper=" + mViewHelper + + '}'; } } diff --git a/core/tests/coretests/src/android/view/ScrollCaptureConnectionTest.java b/core/tests/coretests/src/android/view/ScrollCaptureConnectionTest.java index b9cf1e4a234c8..516fb76eeaf73 100644 --- a/core/tests/coretests/src/android/view/ScrollCaptureConnectionTest.java +++ b/core/tests/coretests/src/android/view/ScrollCaptureConnectionTest.java @@ -16,16 +16,12 @@ package android.view; -import static androidx.test.InstrumentationRegistry.getInstrumentation; import static androidx.test.InstrumentationRegistry.getTargetContext; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.reset; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -35,6 +31,7 @@ import static org.mockito.Mockito.when; import android.graphics.Point; import android.graphics.Rect; import android.os.Handler; +import android.os.ICancellationSignal; import androidx.test.runner.AndroidJUnit4; @@ -42,9 +39,7 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; -import org.mockito.Mockito; import org.mockito.MockitoAnnotations; -import org.mockito.stubbing.Answer; /** * Tests of {@link ScrollCaptureConnection}. @@ -56,261 +51,138 @@ public class ScrollCaptureConnectionTest { private final Point mPositionInWindow = new Point(1, 2); private final Rect mLocalVisibleRect = new Rect(2, 3, 4, 5); private final Rect mScrollBounds = new Rect(3, 4, 5, 6); + private final TestScrollCaptureCallback mCallback = new TestScrollCaptureCallback(); + + private ScrollCaptureTarget mTarget; + private ScrollCaptureConnection mConnection; private Handler mHandler; - private ScrollCaptureTarget mTarget1; @Mock private Surface mSurface; @Mock - private IScrollCaptureCallbacks mConnectionCallbacks; + private IScrollCaptureCallbacks mRemote; @Mock - private View mMockView1; - @Mock - private ScrollCaptureCallback mCallback1; + private View mView; @Before public void setUp() { MockitoAnnotations.initMocks(this); mHandler = new Handler(getTargetContext().getMainLooper()); + when(mSurface.isValid()).thenReturn(true); + when(mView.getScrollCaptureHint()).thenReturn(View.SCROLL_CAPTURE_HINT_INCLUDE); - when(mMockView1.getHandler()).thenReturn(mHandler); - when(mMockView1.getScrollCaptureHint()).thenReturn(View.SCROLL_CAPTURE_HINT_INCLUDE); - - mTarget1 = new ScrollCaptureTarget( - mMockView1, mLocalVisibleRect, mPositionInWindow, mCallback1); - mTarget1.setScrollBounds(mScrollBounds); - } - - /** Test the DelayedAction timeout helper class works as expected. */ - @Test - public void testDelayedAction() { - Runnable action = Mockito.mock(Runnable.class); - ScrollCaptureConnection.DelayedAction delayed = - new ScrollCaptureConnection.DelayedAction(mHandler, 100, action); - try { - Thread.sleep(200); - } catch (InterruptedException ex) { - /* ignore */ - } - getInstrumentation().waitForIdleSync(); - assertFalse(delayed.cancel()); - assertFalse(delayed.timeoutNow()); - verify(action, times(1)).run(); - } - - /** Test the DelayedAction cancel() */ - @Test - public void testDelayedAction_cancel() { - Runnable action = Mockito.mock(Runnable.class); - ScrollCaptureConnection.DelayedAction delayed = - new ScrollCaptureConnection.DelayedAction(mHandler, 100, action); - try { - Thread.sleep(50); - } catch (InterruptedException ex) { - /* ignore */ - } - assertTrue(delayed.cancel()); - assertFalse(delayed.timeoutNow()); - try { - Thread.sleep(200); - } catch (InterruptedException ex) { - /* ignore */ - } - getInstrumentation().waitForIdleSync(); - verify(action, never()).run(); - } - - /** Test the DelayedAction timeoutNow() - for testing only */ - @Test - public void testDelayedAction_timeoutNow() { - Runnable action = Mockito.mock(Runnable.class); - ScrollCaptureConnection.DelayedAction delayed = - new ScrollCaptureConnection.DelayedAction(mHandler, 100, action); - try { - Thread.sleep(50); - } catch (InterruptedException ex) { - /* ignore */ - } - assertTrue(delayed.timeoutNow()); - assertFalse(delayed.cancel()); - getInstrumentation().waitForIdleSync(); - verify(action, times(1)).run(); + mTarget = new ScrollCaptureTarget(mView, mLocalVisibleRect, mPositionInWindow, mCallback); + mTarget.setScrollBounds(mScrollBounds); + mConnection = new ScrollCaptureConnection(Runnable::run, mTarget, mRemote); } /** Test creating a client with valid info */ @Test public void testConstruction() { - new ScrollCaptureConnection(mTarget1, mConnectionCallbacks); + ScrollCaptureTarget target = new ScrollCaptureTarget( + mView, mLocalVisibleRect, mPositionInWindow, mCallback); + target.setScrollBounds(new Rect(1, 2, 3, 4)); + new ScrollCaptureConnection(Runnable::run, target, mRemote); } /** Test creating a client fails if arguments are not valid. */ @Test public void testConstruction_requiresScrollBounds() { try { - mTarget1.setScrollBounds(null); - new ScrollCaptureConnection(mTarget1, mConnectionCallbacks); + mTarget.setScrollBounds(null); + new ScrollCaptureConnection(Runnable::run, mTarget, mRemote); fail("An exception was expected."); } catch (RuntimeException ex) { // Ignore, expected. } } - @SuppressWarnings("SameParameterValue") - private static Answer runRunnable(int arg) { - return invocation -> { - Runnable r = invocation.getArgument(arg); - r.run(); - return null; - }; - } - - @SuppressWarnings("SameParameterValue") - private static Answer reportBufferSent(int sessionArg, long frameNum, Rect capturedArea) { - return invocation -> { - ScrollCaptureSession session = invocation.getArgument(sessionArg); - session.notifyBufferSent(frameNum, capturedArea); - return null; - }; - } - /** @see ScrollCaptureConnection#startCapture(Surface) */ @Test public void testStartCapture() throws Exception { - final ScrollCaptureConnection connection = new ScrollCaptureConnection(mTarget1, - mConnectionCallbacks); + mConnection.startCapture(mSurface); - // Have the session start accepted immediately - doAnswer(runRunnable(1)).when(mCallback1) - .onScrollCaptureStart(any(ScrollCaptureSession.class), any(Runnable.class)); - connection.startCapture(mSurface); - getInstrumentation().waitForIdleSync(); + mCallback.completeStartRequest(); + assertTrue(mConnection.isStarted()); - verify(mCallback1, times(1)) - .onScrollCaptureStart(any(ScrollCaptureSession.class), any(Runnable.class)); - verify(mConnectionCallbacks, times(1)).onCaptureStarted(); - verifyNoMoreInteractions(mConnectionCallbacks); + verify(mRemote, times(1)).onCaptureStarted(); + verifyNoMoreInteractions(mRemote); } @Test - public void testStartCaptureTimeout() throws Exception { - final ScrollCaptureConnection connection = new ScrollCaptureConnection(mTarget1, - mConnectionCallbacks); - connection.startCapture(mSurface); + public void testStartCapture_cancellation() throws Exception { + ICancellationSignal signal = mConnection.startCapture(mSurface); + signal.cancel(); - // Force timeout to fire - connection.getTimeoutAction().timeoutNow(); + mCallback.completeStartRequest(); + assertFalse(mConnection.isStarted()); - getInstrumentation().waitForIdleSync(); - verify(mCallback1, times(1)).onScrollCaptureEnd(any(Runnable.class)); - } - - private void startCapture(ScrollCaptureConnection connection) throws Exception { - doAnswer(runRunnable(1)).when(mCallback1) - .onScrollCaptureStart(any(ScrollCaptureSession.class), any(Runnable.class)); - connection.startCapture(mSurface); - getInstrumentation().waitForIdleSync(); - reset(mCallback1, mConnectionCallbacks); + verifyNoMoreInteractions(mRemote); } /** @see ScrollCaptureConnection#requestImage(Rect) */ @Test public void testRequestImage() throws Exception { - final ScrollCaptureConnection connection = new ScrollCaptureConnection(mTarget1, - mConnectionCallbacks); - startCapture(connection); + mConnection.startCapture(mSurface); + mCallback.completeStartRequest(); + reset(mRemote); - // Stub the callback to complete the request immediately - doAnswer(reportBufferSent(/* sessionArg */ 0, /* frameNum */ 1L, new Rect(1, 2, 3, 4))) - .when(mCallback1) - .onScrollCaptureImageRequest(any(ScrollCaptureSession.class), any(Rect.class)); + mConnection.requestImage(new Rect(1, 2, 3, 4)); + mCallback.completeImageRequest(new Rect(1, 2, 3, 4)); - // Make the inbound binder call - connection.requestImage(new Rect(1, 2, 3, 4)); - - // Wait for handler thread dispatch - getInstrumentation().waitForIdleSync(); - verify(mCallback1, times(1)).onScrollCaptureImageRequest( - any(ScrollCaptureSession.class), eq(new Rect(1, 2, 3, 4))); - - // Wait for binder thread dispatch - getInstrumentation().waitForIdleSync(); - verify(mConnectionCallbacks, times(1)) - .onCaptureBufferSent(eq(1L), eq(new Rect(1, 2, 3, 4))); - - verifyNoMoreInteractions(mCallback1, mConnectionCallbacks); + verify(mRemote, times(1)) + .onImageRequestCompleted(eq(0), eq(new Rect(1, 2, 3, 4))); + verifyNoMoreInteractions(mRemote); } @Test - public void testRequestImageTimeout() throws Exception { - final ScrollCaptureConnection connection = new ScrollCaptureConnection(mTarget1, - mConnectionCallbacks); - startCapture(connection); + public void testRequestImage_cancellation() throws Exception { + mConnection.startCapture(mSurface); + mCallback.completeStartRequest(); + reset(mRemote); - // Make the inbound binder call - connection.requestImage(new Rect(1, 2, 3, 4)); + ICancellationSignal signal = mConnection.requestImage(new Rect(1, 2, 3, 4)); + signal.cancel(); + mCallback.completeImageRequest(new Rect(1, 2, 3, 4)); - // Wait for handler thread dispatch - getInstrumentation().waitForIdleSync(); - verify(mCallback1, times(1)).onScrollCaptureImageRequest( - any(ScrollCaptureSession.class), eq(new Rect(1, 2, 3, 4))); - - // Force timeout to fire - connection.getTimeoutAction().timeoutNow(); - getInstrumentation().waitForIdleSync(); - - // (callback not stubbed, does nothing) - // Timeout triggers request to end capture - verify(mCallback1, times(1)).onScrollCaptureEnd(any(Runnable.class)); - verifyNoMoreInteractions(mCallback1, mConnectionCallbacks); + verifyNoMoreInteractions(mRemote); } /** @see ScrollCaptureConnection#endCapture() */ @Test public void testEndCapture() throws Exception { - final ScrollCaptureConnection connection = new ScrollCaptureConnection(mTarget1, - mConnectionCallbacks); - startCapture(connection); + mConnection.startCapture(mSurface); + mCallback.completeStartRequest(); + reset(mRemote); - // Stub the callback to complete the request immediately - doAnswer(runRunnable(0)) - .when(mCallback1) - .onScrollCaptureEnd(any(Runnable.class)); + mConnection.endCapture(); + mCallback.completeEndRequest(); - // Make the inbound binder call - connection.endCapture(); + // And the reply is sent + verify(mRemote, times(1)).onCaptureEnded(); + verifyNoMoreInteractions(mRemote); + } - // Wait for handler thread dispatch - getInstrumentation().waitForIdleSync(); - verify(mCallback1, times(1)).onScrollCaptureEnd(any(Runnable.class)); + /** @see ScrollCaptureConnection#endCapture() */ + @Test + public void testEndCapture_cancellation() throws Exception { + mConnection.startCapture(mSurface); + mCallback.completeStartRequest(); + reset(mRemote); - // Wait for binder thread dispatch - getInstrumentation().waitForIdleSync(); - verify(mConnectionCallbacks, times(1)).onConnectionClosed(); + ICancellationSignal signal = mConnection.endCapture(); + signal.cancel(); + mCallback.completeEndRequest(); - verifyNoMoreInteractions(mCallback1, mConnectionCallbacks); + verifyNoMoreInteractions(mRemote); } @Test - public void testEndCaptureTimeout() throws Exception { - final ScrollCaptureConnection connection = new ScrollCaptureConnection(mTarget1, - mConnectionCallbacks); - startCapture(connection); - - // Make the inbound binder call - connection.endCapture(); - - // Wait for handler thread dispatch - getInstrumentation().waitForIdleSync(); - verify(mCallback1, times(1)).onScrollCaptureEnd(any(Runnable.class)); - - // Force timeout to fire - connection.getTimeoutAction().timeoutNow(); - - // Wait for binder thread dispatch - getInstrumentation().waitForIdleSync(); - verify(mConnectionCallbacks, times(1)).onConnectionClosed(); - - verifyNoMoreInteractions(mCallback1, mConnectionCallbacks); + public void testClose() throws Exception { + mConnection.close(); + assertFalse(mConnection.isConnected()); + verifyNoMoreInteractions(mRemote); } + } diff --git a/core/tests/coretests/src/android/view/ScrollCaptureSearchResultsTest.java b/core/tests/coretests/src/android/view/ScrollCaptureSearchResultsTest.java new file mode 100644 index 0000000000000..cc229e11dcf27 --- /dev/null +++ b/core/tests/coretests/src/android/view/ScrollCaptureSearchResultsTest.java @@ -0,0 +1,415 @@ +/* + * 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 android.view; + +import static androidx.test.InstrumentationRegistry.getTargetContext; + +import static com.google.common.truth.Truth.assertThat; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import android.annotation.Nullable; +import android.content.Context; +import android.graphics.Point; +import android.graphics.Rect; +import android.os.CancellationSignal; +import android.os.SystemClock; + +import androidx.annotation.NonNull; +import androidx.test.runner.AndroidJUnit4; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +/** + * Tests of {@link ScrollCaptureTargetSelector}. + */ +@RunWith(AndroidJUnit4.class) +public class ScrollCaptureSearchResultsTest { + + + private static final Rect EMPTY_RECT = new Rect(); + private static final String TAG = "Test"; + + private final Executor mDirectExec = Runnable::run; + private Executor mBgExec; + + @Before + public void setUp() { + mBgExec = Executors.newSingleThreadExecutor(); + } + + @Test + public void testNoTargets() { + ScrollCaptureSearchResults results = new ScrollCaptureSearchResults(mDirectExec); + assertTrue(results.isComplete()); + + assertNull("Expected null due to empty queue", results.getTopResult()); + } + + @Test + public void testNoValidTargets() { + ScrollCaptureSearchResults results = new ScrollCaptureSearchResults(mDirectExec); + + FakeScrollCaptureCallback callback1 = new FakeScrollCaptureCallback(mDirectExec); + callback1.setScrollBounds(EMPTY_RECT); + ScrollCaptureTarget target1 = createTarget(callback1, new Rect(20, 30, 40, 50), + new Point(0, 0), View.SCROLL_CAPTURE_HINT_AUTO); + + // Supplies scrollBounds = empty rect + FakeScrollCaptureCallback callback2 = new FakeScrollCaptureCallback(mDirectExec); + callback2.setScrollBounds(EMPTY_RECT); + ScrollCaptureTarget target2 = createTarget(callback2, new Rect(20, 30, 40, 50), + new Point(0, 20), View.SCROLL_CAPTURE_HINT_INCLUDE); + + results.addTarget(target1); + results.addTarget(target2); + + assertTrue(results.isComplete()); + assertNull("Expected null due to no valid targets", results.getTopResult()); + } + + @Test + public void testSingleTarget() { + ScrollCaptureSearchResults results = new ScrollCaptureSearchResults(mDirectExec); + FakeScrollCaptureCallback callback = new FakeScrollCaptureCallback(mDirectExec); + ScrollCaptureTarget target = createTarget(callback, + new Rect(20, 30, 40, 50), new Point(10, 10), + View.SCROLL_CAPTURE_HINT_AUTO); + callback.setScrollBounds(new Rect(2, 2, 18, 18)); + + results.addTarget(target); + assertTrue(results.isComplete()); + + ScrollCaptureTarget result = results.getTopResult(); + assertSame("Excepted the same target as a result", target, result); + assertEquals("result has wrong scroll bounds", + new Rect(2, 2, 18, 18), result.getScrollBounds()); + } + + @Test + public void testSingleTarget_backgroundThread() throws InterruptedException { + FakeScrollCaptureCallback callback1 = new FakeScrollCaptureCallback(mBgExec); + ScrollCaptureTarget target1 = createTarget(callback1, + new Rect(20, 30, 40, 50), new Point(10, 10), + View.SCROLL_CAPTURE_HINT_AUTO); + callback1.setDelay(100); + callback1.setScrollBounds(new Rect(2, 2, 18, 18)); + + ScrollCaptureSearchResults results = new ScrollCaptureSearchResults(mDirectExec); + results.addTarget(target1); + + CountDownLatch latch = new CountDownLatch(1); + results.setOnCompleteListener(latch::countDown); + if (!latch.await(200, TimeUnit.MILLISECONDS)) { + fail("onComplete listener was expected"); + } + + ScrollCaptureTarget result = results.getTopResult(); + assertSame("Excepted the single target1 as a result", target1, result); + assertEquals("Result has wrong scroll bounds", + new Rect(2, 2, 18, 18), result.getScrollBounds()); + } + + @Test + public void testRanking() { + + // 1 - Empty + FakeScrollCaptureCallback callback1 = new FakeScrollCaptureCallback(mDirectExec); + callback1.setScrollBounds(EMPTY_RECT); + ViewGroup targetView1 = new FakeView(getTargetContext(), 0, 0, 60, 60, 1); + ScrollCaptureTarget target1 = createTargetWithView(targetView1, callback1, + new Rect(0, 0, 60, 60), new Point(0, 0), View.SCROLL_CAPTURE_HINT_AUTO); + + // 2 - 10x10 + HINT_INCLUDE + FakeScrollCaptureCallback callback2 = new FakeScrollCaptureCallback(mDirectExec); + callback2.setScrollBounds(new Rect(0, 0, 10, 10)); + ViewGroup targetView2 = new FakeView(getTargetContext(), 0, 0, 60, 60, 2); + ScrollCaptureTarget target2 = createTargetWithView(targetView2, callback2, + new Rect(0, 0, 60, 60), new Point(0, 0), View.SCROLL_CAPTURE_HINT_INCLUDE); + + // 3 - 20x20 + AUTO + FakeScrollCaptureCallback callback3 = new FakeScrollCaptureCallback(mDirectExec); + callback3.setScrollBounds(new Rect(0, 0, 20, 20)); + ViewGroup targetView3 = new FakeView(getTargetContext(), 0, 0, 60, 60, 3); + ScrollCaptureTarget target3 = createTargetWithView(targetView3, callback3, + new Rect(0, 0, 60, 60), new Point(0, 0), View.SCROLL_CAPTURE_HINT_AUTO); + + // 4 - 30x30 + AUTO + FakeScrollCaptureCallback callback4 = new FakeScrollCaptureCallback(mDirectExec); + callback4.setScrollBounds(new Rect(0, 0, 10, 10)); + ViewGroup targetView4 = new FakeView(getTargetContext(), 0, 0, 60, 60, 4); + ScrollCaptureTarget target4 = createTargetWithView(targetView4, callback4, + new Rect(0, 0, 60, 60), new Point(0, 0), View.SCROLL_CAPTURE_HINT_AUTO); + + // 5 - 10x10 + child of #4 + FakeScrollCaptureCallback callback5 = new FakeScrollCaptureCallback(mDirectExec); + callback5.setScrollBounds(new Rect(0, 0, 10, 10)); + ViewGroup targetView5 = new FakeView(getTargetContext(), 0, 0, 60, 60, 5); + ScrollCaptureTarget target5 = createTargetWithView(targetView5, callback5, + new Rect(0, 0, 60, 60), new Point(0, 0), View.SCROLL_CAPTURE_HINT_AUTO); + targetView4.addView(targetView5); + + // 6 - 20x20 + child of #4 + FakeScrollCaptureCallback callback6 = new FakeScrollCaptureCallback(mDirectExec); + callback6.setScrollBounds(new Rect(0, 0, 20, 20)); + ViewGroup targetView6 = new FakeView(getTargetContext(), 0, 0, 60, 60, 6); + ScrollCaptureTarget target6 = createTargetWithView(targetView6, callback6, + new Rect(0, 0, 60, 60), new Point(0, 0), View.SCROLL_CAPTURE_HINT_AUTO); + targetView4.addView(targetView6); + + ScrollCaptureSearchResults results = new ScrollCaptureSearchResults(mDirectExec); + results.addTarget(target1); + results.addTarget(target2); + results.addTarget(target3); + results.addTarget(target4); + results.addTarget(target5); + results.addTarget(target6); + assertTrue(results.isComplete()); + + // Verify "top" result + assertEquals(target2, results.getTopResult()); + + // Verify priority ("best" first) + assertThat(results.getTargets()) + .containsExactly( + target2, + target6, + target5, + target4, + target3, + target1); + } + + /** + * If a timeout expires, late results are ignored. + */ + @Test + public void testTimeout() { + ScrollCaptureSearchResults results = new ScrollCaptureSearchResults(mDirectExec); + + // callback 1, 10x10, hint=AUTO, responds after 100ms from bg thread + FakeScrollCaptureCallback callback1 = new FakeScrollCaptureCallback(mBgExec); + callback1.setScrollBounds(new Rect(5, 5, 15, 15)); + callback1.setDelay(100); + ScrollCaptureTarget target1 = createTarget( + callback1, new Rect(20, 30, 40, 50), new Point(10, 10), + View.SCROLL_CAPTURE_HINT_AUTO); + results.addTarget(target1); + + // callback 2, 20x20, hint=AUTO, responds after 5s from bg thread + FakeScrollCaptureCallback callback2 = new FakeScrollCaptureCallback(mBgExec); + callback2.setScrollBounds(new Rect(0, 0, 20, 20)); + callback2.setDelay(1000); + ScrollCaptureTarget target2 = createTarget( + callback2, new Rect(20, 30, 40, 50), new Point(10, 10), + View.SCROLL_CAPTURE_HINT_AUTO); + results.addTarget(target2); + + // callback 3, 20x20, hint=INCLUDE, responds after 10s from bg thread + FakeScrollCaptureCallback callback3 = new FakeScrollCaptureCallback(mBgExec); + callback3.setScrollBounds(new Rect(0, 0, 20, 20)); + callback3.setDelay(1500); + ScrollCaptureTarget target3 = createTarget( + callback3, new Rect(20, 30, 40, 50), new Point(10, 10), + View.SCROLL_CAPTURE_HINT_INCLUDE); + results.addTarget(target3); + + // callback 1 will be received + // callback 2 & 3 will be ignored due to timeout + SystemClock.sleep(500); + results.finish(); + + ScrollCaptureTarget result = results.getTopResult(); + assertSame("Expected target1 as the result, due to timeouts of others", target1, result); + assertEquals("callback1 should have been called", + 1, callback1.getOnScrollCaptureSearchCount()); + assertEquals("callback2 should have been called", + 1, callback2.getOnScrollCaptureSearchCount()); + assertEquals("callback3 should have been called", + 1, callback3.getOnScrollCaptureSearchCount()); + + assertEquals("result has wrong scroll bounds", + new Rect(5, 5, 15, 15), result.getScrollBounds()); + assertNull("target2 should not have been updated", + target2.getScrollBounds()); + assertNull("target3 should not have been updated", + target3.getScrollBounds()); + } + + @Test + public void testWithCallbackMultipleReplies() { + // Calls response methods 3 times each + ScrollCaptureCallback callback1 = new CallbackStub() { + @Override + public void onScrollCaptureSearch(@NonNull CancellationSignal signal, + @NonNull Consumer onReady) { + onReady.accept(new Rect(1, 2, 3, 4)); + onReady.accept(new Rect(9, 10, 11, 12)); + } + }; + + ScrollCaptureTarget target1 = createTarget(callback1, new Rect(20, 30, 40, 50), + new Point(10, 10), View.SCROLL_CAPTURE_HINT_AUTO); + + ScrollCaptureSearchResults results = new ScrollCaptureSearchResults(mDirectExec); + results.addTarget(target1); + assertTrue(results.isComplete()); + + ScrollCaptureTarget result = results.getTopResult(); + assertSame("Expected target1", target1, result); + assertEquals("result has wrong scroll bounds", + new Rect(1, 2, 3, 4), result.getScrollBounds()); + } + + private void setupTargetView(View view, Rect localVisibleRect, int scrollCaptureHint) { + view.setScrollCaptureHint(scrollCaptureHint); + view.onVisibilityAggregated(true); + // Treat any offset as padding, outset localVisibleRect on all sides and use this as + // child bounds + Rect bounds = new Rect(localVisibleRect); + bounds.inset(-bounds.left, -bounds.top, bounds.left, bounds.top); + view.layout(bounds.left, bounds.top, bounds.right, bounds.bottom); + view.onVisibilityAggregated(true); + } + + private ScrollCaptureTarget createTarget(ScrollCaptureCallback callback, Rect localVisibleRect, + Point positionInWindow, int scrollCaptureHint) { + View mockView = new View(getTargetContext()); + return createTargetWithView(mockView, callback, localVisibleRect, positionInWindow, + scrollCaptureHint); + } + + private ScrollCaptureTarget createTargetWithView(View view, ScrollCaptureCallback callback, + Rect localVisibleRect, Point positionInWindow, int scrollCaptureHint) { + setupTargetView(view, localVisibleRect, scrollCaptureHint); + return new ScrollCaptureTarget(view, localVisibleRect, positionInWindow, callback); + } + + + static class FakeView extends ViewGroup implements ViewParent { + FakeView(Context context, int l, int t, int r, int b, int id) { + super(context); + layout(l, t, r, b); + setId(id); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + } + } + + static class FakeScrollCaptureCallback implements ScrollCaptureCallback { + private final Executor mExecutor; + private Rect mScrollBounds; + private long mDelayMillis; + private int mOnScrollCaptureSearchCount; + FakeScrollCaptureCallback(Executor executor) { + mExecutor = executor; + } + public int getOnScrollCaptureSearchCount() { + return mOnScrollCaptureSearchCount; + } + + @Override + public void onScrollCaptureSearch(CancellationSignal signal, Consumer onReady) { + mOnScrollCaptureSearchCount++; + run(() -> { + Rect b = getScrollBounds(); + onReady.accept(b); + }); + } + + @Override + public void onScrollCaptureStart(ScrollCaptureSession session, CancellationSignal signal, + Runnable onReady) { + run(onReady); + } + + @Override + public void onScrollCaptureImageRequest(ScrollCaptureSession session, + CancellationSignal signal, Rect captureArea, Consumer onReady) { + run(() -> onReady.accept(captureArea)); + } + + @Override + public void onScrollCaptureEnd(Runnable onReady) { + run(onReady); + } + + public void setScrollBounds(@Nullable Rect scrollBounds) { + mScrollBounds = scrollBounds; + } + + public void setDelay(long delayMillis) { + mDelayMillis = delayMillis; + } + + protected Rect getScrollBounds() { + return mScrollBounds; + } + + protected void run(Runnable r) { + mExecutor.execute(() -> { + delay(); + r.run(); + }); + } + + protected void delay() { + if (mDelayMillis > 0) { + try { + Thread.sleep(mDelayMillis); + } catch (InterruptedException e) { + // Ignore + } + } + } + } + static class CallbackStub implements ScrollCaptureCallback { + @Override + public void onScrollCaptureSearch(@NonNull CancellationSignal signal, + @NonNull Consumer onReady) { + } + + @Override + public void onScrollCaptureStart(@NonNull ScrollCaptureSession session, + @NonNull CancellationSignal signal, @NonNull Runnable onReady) { + } + + @Override + public void onScrollCaptureImageRequest(@NonNull ScrollCaptureSession session, + @NonNull CancellationSignal signal, @NonNull Rect captureArea, + Consumer onReady) { + } + + @Override + public void onScrollCaptureEnd(@NonNull Runnable onReady) { + } + } +} diff --git a/core/tests/coretests/src/android/view/ScrollCaptureTargetResolverTest.java b/core/tests/coretests/src/android/view/ScrollCaptureTargetResolverTest.java deleted file mode 100644 index 8b21b8ecee890..0000000000000 --- a/core/tests/coretests/src/android/view/ScrollCaptureTargetResolverTest.java +++ /dev/null @@ -1,498 +0,0 @@ -/* - * 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 android.view; - -import static androidx.test.InstrumentationRegistry.getTargetContext; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertSame; - -import android.annotation.Nullable; -import android.content.Context; -import android.graphics.Point; -import android.graphics.Rect; -import android.os.Handler; - -import androidx.test.runner.AndroidJUnit4; - -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.util.LinkedList; -import java.util.function.Consumer; - -/** - * Tests of {@link ScrollCaptureTargetResolver}. - */ -@RunWith(AndroidJUnit4.class) -public class ScrollCaptureTargetResolverTest { - - private static final long TEST_TIMEOUT_MS = 2000; - private static final long RESOLVER_TIMEOUT_MS = 1000; - - private Handler mHandler; - private TargetConsumer mTargetConsumer; - - @Before - public void setUp() { - mTargetConsumer = new TargetConsumer(); - mHandler = new Handler(getTargetContext().getMainLooper()); - } - - @Test(timeout = TEST_TIMEOUT_MS) - public void testEmptyQueue() throws InterruptedException { - ScrollCaptureTargetResolver resolver = new ScrollCaptureTargetResolver(new LinkedList<>()); - resolver.start(mHandler, RESOLVER_TIMEOUT_MS, mTargetConsumer); - - // Test only - resolver.waitForResult(); - - ScrollCaptureTarget result = mTargetConsumer.getLastValue(); - assertNull("Expected null due to empty queue", result); - } - - @Test(timeout = TEST_TIMEOUT_MS) - public void testNoValidTargets() throws InterruptedException { - LinkedList targetQueue = new LinkedList<>(); - - // Supplies scrollBounds = null - FakeScrollCaptureCallback callback1 = new FakeScrollCaptureCallback(); - callback1.setScrollBounds(null); - ScrollCaptureTarget target1 = createTarget(callback1, new Rect(20, 30, 40, 50), - new Point(0, 0), View.SCROLL_CAPTURE_HINT_AUTO); - - // Supplies scrollBounds = empty rect - FakeScrollCaptureCallback callback2 = new FakeScrollCaptureCallback(); - callback2.setScrollBounds(new Rect()); - ScrollCaptureTarget target2 = createTarget(callback2, new Rect(20, 30, 40, 50), - new Point(0, 20), View.SCROLL_CAPTURE_HINT_INCLUDE); - - targetQueue.add(target1); - targetQueue.add(target2); - - ScrollCaptureTargetResolver resolver = new ScrollCaptureTargetResolver(targetQueue); - resolver.start(mHandler, RESOLVER_TIMEOUT_MS, mTargetConsumer); - - // Test only - resolver.waitForResult(); - - ScrollCaptureTarget result = mTargetConsumer.getLastValue(); - assertNull("Expected null due to no valid targets", result); - } - - @Test(timeout = TEST_TIMEOUT_MS) - public void testSingleTarget() throws InterruptedException { - FakeScrollCaptureCallback callback = new FakeScrollCaptureCallback(); - ScrollCaptureTarget target = createTarget(callback, - new Rect(20, 30, 40, 50), new Point(10, 10), - View.SCROLL_CAPTURE_HINT_AUTO); - callback.setScrollBounds(new Rect(2, 2, 18, 18)); - - LinkedList targetQueue = new LinkedList<>(); - targetQueue.add(target); - ScrollCaptureTargetResolver resolver = new ScrollCaptureTargetResolver(targetQueue); - resolver.start(mHandler, RESOLVER_TIMEOUT_MS, mTargetConsumer); - - // Test only - resolver.waitForResult(); - - ScrollCaptureTarget result = mTargetConsumer.getLastValue(); - assertSame("Excepted the same target as a result", target, result); - assertEquals("result has wrong scroll bounds", - new Rect(2, 2, 18, 18), result.getScrollBounds()); - } - - @Test(timeout = TEST_TIMEOUT_MS) - public void testSingleTarget_backgroundThread() throws InterruptedException { - BackgroundTestCallback callback1 = new BackgroundTestCallback(); - ScrollCaptureTarget target1 = createTarget(callback1, - new Rect(20, 30, 40, 50), new Point(10, 10), - View.SCROLL_CAPTURE_HINT_AUTO); - callback1.setDelay(100); - callback1.setScrollBounds(new Rect(2, 2, 18, 18)); - - LinkedList targetQueue = new LinkedList<>(); - targetQueue.add(target1); - - ScrollCaptureTargetResolver resolver = new ScrollCaptureTargetResolver(targetQueue); - resolver.start(mHandler, RESOLVER_TIMEOUT_MS, mTargetConsumer); - - // Test only - resolver.waitForResult(); - - ScrollCaptureTarget result = mTargetConsumer.getLastValue(); - assertSame("Excepted the single target1 as a result", target1, result); - assertEquals("Result has wrong scroll bounds", - new Rect(2, 2, 18, 18), result.getScrollBounds()); - } - - @Test(timeout = TEST_TIMEOUT_MS) - public void testPreferNonEmptyBounds() throws InterruptedException { - LinkedList targetQueue = new LinkedList<>(); - - FakeScrollCaptureCallback callback1 = new FakeScrollCaptureCallback(); - callback1.setScrollBounds(new Rect()); - ScrollCaptureTarget target1 = createTarget(callback1, new Rect(20, 30, 40, 50), - new Point(0, 0), View.SCROLL_CAPTURE_HINT_AUTO); - - FakeScrollCaptureCallback callback2 = new FakeScrollCaptureCallback(); - callback2.setScrollBounds(new Rect(0, 0, 20, 20)); - ScrollCaptureTarget target2 = createTarget(callback2, new Rect(20, 30, 40, 50), - new Point(0, 20), View.SCROLL_CAPTURE_HINT_INCLUDE); - - FakeScrollCaptureCallback callback3 = new FakeScrollCaptureCallback(); - callback3.setScrollBounds(null); - ScrollCaptureTarget target3 = createTarget(callback3, new Rect(20, 30, 40, 50), - new Point(0, 40), View.SCROLL_CAPTURE_HINT_AUTO); - - targetQueue.add(target1); - targetQueue.add(target2); // scrollBounds not null or empty() - targetQueue.add(target3); - - ScrollCaptureTargetResolver resolver = new ScrollCaptureTargetResolver(targetQueue); - resolver.start(mHandler, RESOLVER_TIMEOUT_MS, mTargetConsumer); - resolver.waitForResult(); - - ScrollCaptureTarget result = mTargetConsumer.getLastValue(); - assertEquals("Expected " + target2 + " as a result", target2, result); - assertEquals("result has wrong scroll bounds", - new Rect(0, 0, 20, 20), result.getScrollBounds()); - } - - @Test(timeout = TEST_TIMEOUT_MS) - public void testPreferHintInclude() throws InterruptedException { - LinkedList targetQueue = new LinkedList<>(); - - FakeScrollCaptureCallback callback1 = new FakeScrollCaptureCallback(); - callback1.setScrollBounds(new Rect(0, 0, 20, 20)); - ScrollCaptureTarget target1 = createTarget(callback1, new Rect(20, 30, 40, 50), - new Point(0, 0), View.SCROLL_CAPTURE_HINT_AUTO); - - FakeScrollCaptureCallback callback2 = new FakeScrollCaptureCallback(); - callback2.setScrollBounds(new Rect(1, 1, 19, 19)); - ScrollCaptureTarget target2 = createTarget(callback2, new Rect(20, 30, 40, 50), - new Point(0, 20), View.SCROLL_CAPTURE_HINT_INCLUDE); - - FakeScrollCaptureCallback callback3 = new FakeScrollCaptureCallback(); - callback3.setScrollBounds(new Rect(2, 2, 18, 18)); - ScrollCaptureTarget target3 = createTarget(callback3, new Rect(20, 30, 40, 50), - new Point(0, 40), View.SCROLL_CAPTURE_HINT_AUTO); - - targetQueue.add(target1); - targetQueue.add(target2); // * INCLUDE > AUTO - targetQueue.add(target3); - - ScrollCaptureTargetResolver resolver = new ScrollCaptureTargetResolver(targetQueue); - resolver.start(mHandler, RESOLVER_TIMEOUT_MS, mTargetConsumer); - - resolver.waitForResult(); - - ScrollCaptureTarget result = mTargetConsumer.getLastValue(); - assertEquals("input = " + targetQueue + " Expected " + target2 - + " as the result, due to hint=INCLUDE", target2, result); - assertEquals("result has wrong scroll bounds", - new Rect(1, 1, 19, 19), result.getScrollBounds()); - } - - @Test(timeout = TEST_TIMEOUT_MS) - public void testDescendantPreferred() throws InterruptedException { - LinkedList targetQueue = new LinkedList<>(); - - ViewGroup targetView1 = new FakeRootView(getTargetContext(), 0, 0, 60, 60); // 60x60 - ViewGroup targetView2 = new FakeRootView(getTargetContext(), 20, 30, 40, 50); // 20x20 - ViewGroup targetView3 = new FakeRootView(getTargetContext(), 5, 5, 15, 15); // 10x10 - - targetView1.addView(targetView2); - targetView2.addView(targetView3); - - // Create first target with an unrelated parent - FakeScrollCaptureCallback callback1 = new FakeScrollCaptureCallback(); - callback1.setScrollBounds(new Rect(0, 0, 60, 60)); - ScrollCaptureTarget target1 = createTargetWithView(targetView1, callback1, - new Rect(0, 0, 60, 60), - new Point(0, 0), View.SCROLL_CAPTURE_HINT_AUTO); - - // Create second target associated with a view within parent2 - FakeScrollCaptureCallback callback2 = new FakeScrollCaptureCallback(); - callback2.setScrollBounds(new Rect(0, 0, 20, 20)); - ScrollCaptureTarget target2 = createTargetWithView(targetView2, callback2, - new Rect(0, 0, 20, 20), - new Point(20, 30), View.SCROLL_CAPTURE_HINT_AUTO); - - // Create third target associated with a view within parent3 - FakeScrollCaptureCallback callback3 = new FakeScrollCaptureCallback(); - callback3.setScrollBounds(new Rect(0, 0, 15, 15)); - ScrollCaptureTarget target3 = createTargetWithView(targetView3, callback3, - new Rect(0, 0, 15, 15), - new Point(25, 35), View.SCROLL_CAPTURE_HINT_AUTO); - - targetQueue.add(target1); // auto, 60x60 - targetQueue.add(target2); // auto, 20x20 - targetQueue.add(target3); // auto, 15x15 <- innermost scrollable - - ScrollCaptureTargetResolver resolver = new ScrollCaptureTargetResolver(targetQueue); - resolver.start(mHandler, RESOLVER_TIMEOUT_MS, mTargetConsumer); - - // Test only - resolver.waitForResult(); - - ScrollCaptureTarget result = mTargetConsumer.getLastValue(); - assertSame("Expected target3 as the result, due to relation", target3, result); - assertEquals("result has wrong scroll bounds", - new Rect(0, 0, 15, 15), result.getScrollBounds()); - } - - /** - * If a timeout expires, late results are ignored. - */ - @Test(timeout = TEST_TIMEOUT_MS) - public void testTimeout() throws InterruptedException { - LinkedList targetQueue = new LinkedList<>(); - - // callback 1, 10x10, hint=AUTO, responds immediately from bg thread - BackgroundTestCallback callback1 = new BackgroundTestCallback(); - callback1.setScrollBounds(new Rect(5, 5, 15, 15)); - ScrollCaptureTarget target1 = createTarget( - callback1, new Rect(20, 30, 40, 50), new Point(10, 10), - View.SCROLL_CAPTURE_HINT_AUTO); - targetQueue.add(target1); - - // callback 2, 20x20, hint=AUTO, responds after 5s from bg thread - BackgroundTestCallback callback2 = new BackgroundTestCallback(); - callback2.setScrollBounds(new Rect(0, 0, 20, 20)); - callback2.setDelay(5000); - ScrollCaptureTarget target2 = createTarget( - callback2, new Rect(20, 30, 40, 50), new Point(10, 10), - View.SCROLL_CAPTURE_HINT_AUTO); - targetQueue.add(target2); - - // callback 3, 20x20, hint=INCLUDE, responds after 10s from bg thread - BackgroundTestCallback callback3 = new BackgroundTestCallback(); - callback3.setScrollBounds(new Rect(0, 0, 20, 20)); - callback3.setDelay(10000); - ScrollCaptureTarget target3 = createTarget( - callback3, new Rect(20, 30, 40, 50), new Point(10, 10), - View.SCROLL_CAPTURE_HINT_INCLUDE); - targetQueue.add(target3); - - // callback 1 will be received - // callback 2 & 3 will be ignored due to timeout - - ScrollCaptureTargetResolver resolver = new ScrollCaptureTargetResolver(targetQueue); - resolver.start(mHandler, RESOLVER_TIMEOUT_MS, mTargetConsumer); - - resolver.waitForResult(); - - ScrollCaptureTarget result = mTargetConsumer.getLastValue(); - assertSame("Expected target1 as the result, due to timeouts of others", target1, result); - assertEquals("result has wrong scroll bounds", - new Rect(5, 5, 15, 15), result.getScrollBounds()); - assertEquals("callback1 should have been called", - 1, callback1.getOnScrollCaptureSearchCount()); - assertEquals("callback2 should have been called", - 1, callback2.getOnScrollCaptureSearchCount()); - assertEquals("callback3 should have been called", - 1, callback3.getOnScrollCaptureSearchCount()); - } - - @Test(timeout = TEST_TIMEOUT_MS) - public void testWithCallbackMultipleReplies() throws InterruptedException { - // Calls response methods 3 times each - RepeatingCaptureCallback callback1 = new RepeatingCaptureCallback(3); - callback1.setScrollBounds(new Rect(2, 2, 18, 18)); - ScrollCaptureTarget target1 = createTarget(callback1, new Rect(20, 30, 40, 50), - new Point(10, 10), View.SCROLL_CAPTURE_HINT_AUTO); - - FakeScrollCaptureCallback callback2 = new FakeScrollCaptureCallback(); - callback2.setScrollBounds(new Rect(0, 0, 20, 20)); - ScrollCaptureTarget target2 = createTarget(callback2, new Rect(20, 30, 40, 50), - new Point(10, 10), View.SCROLL_CAPTURE_HINT_AUTO); - - LinkedList targetQueue = new LinkedList<>(); - targetQueue.add(target1); - targetQueue.add(target2); - - ScrollCaptureTargetResolver resolver = new ScrollCaptureTargetResolver(targetQueue); - resolver.start(mHandler, RESOLVER_TIMEOUT_MS, mTargetConsumer); - - resolver.waitForResult(); - - ScrollCaptureTarget result = mTargetConsumer.getLastValue(); - assertSame("Expected target2 as the result, due to hint=INCLUDE", target2, result); - assertEquals("result has wrong scroll bounds", - new Rect(0, 0, 20, 20), result.getScrollBounds()); - assertEquals("callback1 should have been called once", - 1, callback1.getOnScrollCaptureSearchCount()); - assertEquals("callback2 should have been called once", - 1, callback2.getOnScrollCaptureSearchCount()); - } - - private static class TargetConsumer implements Consumer { - volatile ScrollCaptureTarget mResult; - int mAcceptCount; - - ScrollCaptureTarget getLastValue() { - return mResult; - } - - int acceptCount() { - return mAcceptCount; - } - - @Override - public void accept(@Nullable ScrollCaptureTarget t) { - mAcceptCount++; - mResult = t; - } - } - - private void setupTargetView(View view, Rect localVisibleRect, int scrollCaptureHint) { - view.setScrollCaptureHint(scrollCaptureHint); - view.onVisibilityAggregated(true); - // Treat any offset as padding, outset localVisibleRect on all sides and use this as - // child bounds - Rect bounds = new Rect(localVisibleRect); - bounds.inset(-bounds.left, -bounds.top, bounds.left, bounds.top); - view.layout(bounds.left, bounds.top, bounds.right, bounds.bottom); - view.onVisibilityAggregated(true); - } - - private ScrollCaptureTarget createTarget(ScrollCaptureCallback callback, Rect localVisibleRect, - Point positionInWindow, int scrollCaptureHint) { - View mockView = new View(getTargetContext()); - return createTargetWithView(mockView, callback, localVisibleRect, positionInWindow, - scrollCaptureHint); - } - - private ScrollCaptureTarget createTargetWithView(View view, ScrollCaptureCallback callback, - Rect localVisibleRect, Point positionInWindow, int scrollCaptureHint) { - setupTargetView(view, localVisibleRect, scrollCaptureHint); - return new ScrollCaptureTarget(view, localVisibleRect, positionInWindow, callback); - } - - - static class FakeRootView extends ViewGroup implements ViewParent { - FakeRootView(Context context, int l, int t, int r, int b) { - super(context); - layout(l, t, r, b); - } - - @Override - protected void onLayout(boolean changed, int l, int t, int r, int b) { - } - } - - static class FakeScrollCaptureCallback implements ScrollCaptureCallback { - private Rect mScrollBounds; - private long mDelayMillis; - private int mOnScrollCaptureSearchCount; - - public int getOnScrollCaptureSearchCount() { - return mOnScrollCaptureSearchCount; - } - - @Override - public void onScrollCaptureSearch(Consumer onReady) { - mOnScrollCaptureSearchCount++; - run(() -> { - Rect b = getScrollBounds(); - onReady.accept(b); - }); - } - - @Override - public void onScrollCaptureStart(ScrollCaptureSession session, Runnable onReady) { - run(onReady); - } - - @Override - public void onScrollCaptureImageRequest(ScrollCaptureSession session, Rect captureArea) { - run(() -> session.notifyBufferSent(0, captureArea)); - } - - @Override - public void onScrollCaptureEnd(Runnable onReady) { - run(onReady); - } - - public void setScrollBounds(@Nullable Rect scrollBounds) { - mScrollBounds = scrollBounds; - } - - public void setDelay(long delayMillis) { - mDelayMillis = delayMillis; - } - - protected Rect getScrollBounds() { - return mScrollBounds; - } - - protected void run(Runnable r) { - delay(); - r.run(); - } - - protected void delay() { - if (mDelayMillis > 0) { - try { - Thread.sleep(mDelayMillis); - } catch (InterruptedException e) { - // Ignore - } - } - } - } - - static class RepeatingCaptureCallback extends FakeScrollCaptureCallback { - private int mRepeatCount; - - RepeatingCaptureCallback(int repeatCount) { - mRepeatCount = repeatCount; - } - - protected void run(Runnable r) { - delay(); - for (int i = 0; i < mRepeatCount; i++) { - r.run(); - } - } - } - - /** Response to async calls on an arbitrary background thread */ - static class BackgroundTestCallback extends FakeScrollCaptureCallback { - static int sCount = 0; - private void runOnBackgroundThread(Runnable r) { - final Runnable target = () -> { - delay(); - r.run(); - }; - Thread t = new Thread(target); - synchronized (BackgroundTestCallback.this) { - sCount++; - } - t.setName("Background-Thread-" + sCount); - t.start(); - } - - @Override - protected void run(Runnable r) { - runOnBackgroundThread(r); - } - } -} diff --git a/core/tests/coretests/src/android/view/TestScrollCaptureCallback.java b/core/tests/coretests/src/android/view/TestScrollCaptureCallback.java new file mode 100644 index 0000000000000..1520c6e34a954 --- /dev/null +++ b/core/tests/coretests/src/android/view/TestScrollCaptureCallback.java @@ -0,0 +1,86 @@ +/* + * 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 android.view; + +import static org.junit.Assert.*; + +import android.graphics.Rect; +import android.os.CancellationSignal; + +import androidx.annotation.NonNull; + +import java.util.function.Consumer; + +class TestScrollCaptureCallback implements ScrollCaptureCallback { + private Consumer mSearchConsumer; + private Runnable mStartOnReady; + private Consumer mImageOnComplete; + private Runnable mOnEndReady; + private volatile int mModCount; + + @Override + public void onScrollCaptureSearch(@NonNull CancellationSignal signal, + @NonNull Consumer onReady) { + mSearchConsumer = onReady; + mModCount++; + } + + @Override + public void onScrollCaptureStart(@NonNull ScrollCaptureSession session, + @NonNull CancellationSignal signal, @NonNull Runnable onReady) { + mStartOnReady = onReady; + mModCount++; + } + + @Override + public void onScrollCaptureImageRequest(@NonNull ScrollCaptureSession session, + @NonNull CancellationSignal signal, @NonNull Rect captureArea, + @NonNull Consumer onComplete) { + mImageOnComplete = onComplete; + mModCount++; + } + + @Override + public void onScrollCaptureEnd(@NonNull Runnable onReady) { + mOnEndReady = onReady; + } + + void completeSearchRequest(Rect scrollBounds) { + assertNotNull("Did not receive search request", mSearchConsumer); + mSearchConsumer.accept(scrollBounds); + mModCount++; + } + + void verifyZeroInteractions() { + assertEquals("Expected zero interactions", 0, mModCount); + } + + void completeStartRequest() { + assertNotNull("Did not receive start request", mStartOnReady); + mStartOnReady.run(); + } + + void completeImageRequest(Rect captured) { + assertNotNull("Did not receive image request", mImageOnComplete); + mImageOnComplete.accept(captured); + } + + void completeEndRequest() { + assertNotNull("Did not receive end request", mOnEndReady); + mOnEndReady.run(); + } +} diff --git a/core/tests/coretests/src/android/view/ViewGroupScrollCaptureTest.java b/core/tests/coretests/src/android/view/ViewGroupScrollCaptureTest.java index 3af0533e763ca..41cd4c562bd88 100644 --- a/core/tests/coretests/src/android/view/ViewGroupScrollCaptureTest.java +++ b/core/tests/coretests/src/android/view/ViewGroupScrollCaptureTest.java @@ -19,7 +19,9 @@ package android.view; import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.testng.AssertJUnit.assertSame; @@ -27,19 +29,20 @@ import android.annotation.Nullable; import android.content.Context; import android.graphics.Point; import android.graphics.Rect; +import android.os.CancellationSignal; import android.platform.test.annotations.Presubmit; +import androidx.annotation.NonNull; import androidx.test.filters.FlakyTest; import androidx.test.filters.MediumTest; import androidx.test.filters.SmallTest; import org.junit.Test; import org.junit.runner.RunWith; -import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; -import java.util.LinkedList; -import java.util.Queue; +import java.util.concurrent.Executor; +import java.util.function.Consumer; /** * Exercises Scroll Capture search in {@link ViewGroup}. @@ -50,10 +53,7 @@ import java.util.Queue; @RunWith(MockitoJUnitRunner.class) public class ViewGroupScrollCaptureTest { - @Mock - ScrollCaptureCallback mMockCallback; - @Mock - ScrollCaptureCallback mMockCallback2; + private static final Executor DIRECT_EXECUTOR = Runnable::run; /** Make sure the hint flags are saved and loaded correctly. */ @Test @@ -103,25 +103,24 @@ public class ViewGroupScrollCaptureTest { public void testDispatchScrollCaptureSearch_noCallback_hintAuto() throws Exception { final Context context = getInstrumentation().getContext(); final MockViewGroup viewGroup = new MockViewGroup(context, 0, 0, 200, 200); + TestScrollCaptureCallback callback = new TestScrollCaptureCallback(); // When system internal scroll capture is requested, this callback is returned. - viewGroup.setScrollCaptureCallbackInternalForTest(mMockCallback); + viewGroup.setScrollCaptureCallbackInternalForTest(callback); Rect localVisibleRect = new Rect(0, 0, 200, 200); Point windowOffset = new Point(); - LinkedList targetList = new LinkedList<>(); + ScrollCaptureSearchResults results = new ScrollCaptureSearchResults(DIRECT_EXECUTOR); // Dispatch - viewGroup.dispatchScrollCaptureSearch(localVisibleRect, windowOffset, targetList); - - // Verify the system checked for fallback support - viewGroup.assertDispatchScrollCaptureCount(1); - viewGroup.assertLastDispatchScrollCaptureArgs(localVisibleRect, windowOffset); + viewGroup.dispatchScrollCaptureSearch(localVisibleRect, windowOffset, results::addTarget); + callback.completeSearchRequest(new Rect(1, 2, 3, 4)); + assertTrue(results.isComplete()); // Verify the target is as expected. - assertEquals(1, targetList.size()); - ScrollCaptureTarget target = targetList.get(0); - assertSame("Target has the wrong callback", mMockCallback, target.getCallback()); + ScrollCaptureTarget target = results.getTopResult(); + assertNotNull("Target not found", target); + assertSame("Target has the wrong callback", callback, target.getCallback()); assertSame("Target has the wrong View", viewGroup, target.getContainingView()); assertEquals("Target hint is incorrect", View.SCROLL_CAPTURE_HINT_AUTO, target.getContainingView().getScrollCaptureHint()); @@ -139,18 +138,22 @@ public class ViewGroupScrollCaptureTest { final MockViewGroup viewGroup = new MockViewGroup(context, 0, 0, 200, 200, View.SCROLL_CAPTURE_HINT_EXCLUDE); + TestScrollCaptureCallback callback = new TestScrollCaptureCallback(); + // When system internal scroll capture is requested, this callback is returned. - viewGroup.setScrollCaptureCallbackInternalForTest(mMockCallback); + viewGroup.setScrollCaptureCallbackInternalForTest(callback); Rect localVisibleRect = new Rect(0, 0, 200, 200); Point windowOffset = new Point(); - LinkedList targetList = new LinkedList<>(); + ScrollCaptureSearchResults results = new ScrollCaptureSearchResults(DIRECT_EXECUTOR); + assertTrue(results.isComplete()); // Dispatch - viewGroup.dispatchScrollCaptureSearch(localVisibleRect, windowOffset, targetList); + viewGroup.dispatchScrollCaptureSearch(localVisibleRect, windowOffset, results::addTarget); + callback.verifyZeroInteractions(); // Verify the results. - assertEquals("Target list size should be zero.", 0, targetList.size()); + assertTrue("Results should be empty.", results.isEmpty()); } /** @@ -164,27 +167,34 @@ public class ViewGroupScrollCaptureTest { final Context context = getInstrumentation().getContext(); MockViewGroup viewGroup = new MockViewGroup(context, 0, 0, 200, 200); + TestScrollCaptureCallback callback = new TestScrollCaptureCallback(); + TestScrollCaptureCallback callback2 = new TestScrollCaptureCallback(); + // With an already provided scroll capture callback - viewGroup.setScrollCaptureCallback(mMockCallback); + viewGroup.setScrollCaptureCallback(callback); // When system internal scroll capture is requested, this callback is returned. - viewGroup.setScrollCaptureCallbackInternalForTest(mMockCallback); + viewGroup.setScrollCaptureCallbackInternalForTest(callback2); Rect localVisibleRect = new Rect(0, 0, 200, 200); Point windowOffset = new Point(); - LinkedList targetList = new LinkedList<>(); + ScrollCaptureSearchResults results = new ScrollCaptureSearchResults(DIRECT_EXECUTOR); // Dispatch to the ViewGroup - viewGroup.dispatchScrollCaptureSearch(localVisibleRect, windowOffset, targetList); - - // Confirm that framework support was not requested, - // because this view already had a callback set. - viewGroup.assertCreateScrollCaptureCallbackInternalCount(0); + viewGroup.dispatchScrollCaptureSearch(localVisibleRect, windowOffset, results::addTarget); + callback.completeSearchRequest(new Rect(1, 2, 3, 4)); // Verify the target is as expected. - assertEquals(1, targetList.size()); - ScrollCaptureTarget target = targetList.get(0); - assertSame("Target has the wrong callback", mMockCallback, target.getCallback()); + assertFalse(results.isEmpty()); + assertTrue(results.isComplete()); + + // internal framework callback was not requested + callback2.verifyZeroInteractions(); + + ScrollCaptureTarget target = results.getTopResult(); + + assertNotNull("Target not found", target); + assertSame("Target has the wrong callback", callback, target.getCallback()); assertSame("Target has the wrong View", viewGroup, target.getContainingView()); assertEquals("Target hint is incorrect", View.SCROLL_CAPTURE_HINT_AUTO, target.getContainingView().getScrollCaptureHint()); @@ -201,22 +211,22 @@ public class ViewGroupScrollCaptureTest { final Context context = getInstrumentation().getContext(); MockViewGroup viewGroup = new MockViewGroup(context, 0, 0, 200, 200, View.SCROLL_CAPTURE_HINT_EXCLUDE); + + TestScrollCaptureCallback callback = new TestScrollCaptureCallback(); + // With an already provided scroll capture callback - viewGroup.setScrollCaptureCallback(mMockCallback); + viewGroup.setScrollCaptureCallback(callback); Rect localVisibleRect = new Rect(0, 0, 200, 200); Point windowOffset = new Point(); - LinkedList targetList = new LinkedList<>(); + ScrollCaptureSearchResults results = new ScrollCaptureSearchResults(DIRECT_EXECUTOR); // Dispatch to the ViewGroup itself - viewGroup.dispatchScrollCaptureSearch(localVisibleRect, windowOffset, targetList); - - // Confirm that framework support was not requested, because this view is excluded. - // (And because this view has a callback set.) - viewGroup.assertCreateScrollCaptureCallbackInternalCount(0); + viewGroup.dispatchScrollCaptureSearch(localVisibleRect, windowOffset, results::addTarget); + callback.verifyZeroInteractions(); // Has callback, but hint=excluded, so excluded. - assertTrue(targetList.isEmpty()); + assertNull(results.getTopResult()); } /** @@ -252,37 +262,43 @@ public class ViewGroupScrollCaptureTest { // | | | // +---------------+----------+ (200,200) - // View 1 is clipped and not visible. + // View 1 is fully clipped and not visible. final MockView view1 = new MockView(context, 0, 0, 200, 25); viewGroup.addView(view1); - // View 2 is partially visible. + // View 2 is partially visible. (75x75) final MockView view2 = new MockView(context, 0, 25, 150, 100); viewGroup.addView(view2); - // View 3 is partially visible. + TestScrollCaptureCallback callback1 = new TestScrollCaptureCallback(); + + // View 3 is partially visible (175x50) // Pretend View3 can scroll by having framework provide fallback support final MockView view3 = new MockView(context, 0, 100, 200, 200); // When system internal scroll capture is requested for this view, return this callback. - view3.setScrollCaptureCallbackInternalForTest(mMockCallback); + view3.setScrollCaptureCallbackInternalForTest(callback1); viewGroup.addView(view3); // View 4 is invisible and should be ignored. final MockView view4 = new MockView(context, 150, 25, 200, 100, View.INVISIBLE); viewGroup.addView(view4); - // View 4 is invisible and should be ignored. + TestScrollCaptureCallback callback2 = new TestScrollCaptureCallback(); + + // View 5 is partially visible and explicitly included via flag. (25x50) final MockView view5 = new MockView(context, 150, 100, 200, 200); - // When system internal scroll capture is requested for this view, return this callback. - view5.setScrollCaptureCallback(mMockCallback2); + view5.setScrollCaptureCallback(callback2); view5.setScrollCaptureHint(View.SCROLL_CAPTURE_HINT_INCLUDE); viewGroup.addView(view5); // Where targets are added - final LinkedList targetList = new LinkedList<>(); + final ScrollCaptureSearchResults results = new ScrollCaptureSearchResults(DIRECT_EXECUTOR); // Dispatch to the ViewGroup - viewGroup.dispatchScrollCaptureSearch(localVisibleRect, windowOffset, targetList); + viewGroup.dispatchScrollCaptureSearch(localVisibleRect, windowOffset, results::addTarget); + callback1.completeSearchRequest(new Rect(0, 0, 200, 100)); + callback2.completeSearchRequest(new Rect(0, 0, 50, 100)); + assertTrue(results.isComplete()); // View 1 is entirely clipped by the parent and not visible, dispatch // skips this view entirely. @@ -317,18 +333,14 @@ public class ViewGroupScrollCaptureTest { view5.assertCreateScrollCaptureCallbackInternalCount(0); // 2 views should have been returned, view3 & view5 - assertEquals(2, targetList.size()); + assertFalse(results.isEmpty()); + assertTrue(results.isComplete()); - ScrollCaptureTarget target = targetList.get(0); - assertSame("First target has the wrong View", view3, target.getContainingView()); - assertSame("First target has the wrong callback", mMockCallback, target.getCallback()); - assertEquals("First target hint is incorrect", View.SCROLL_CAPTURE_HINT_AUTO, - target.getContainingView().getScrollCaptureHint()); - - target = targetList.get(1); - assertSame("Second target has the wrong View", view5, target.getContainingView()); - assertSame("Second target has the wrong callback", mMockCallback2, target.getCallback()); - assertEquals("Second target hint is incorrect", View.SCROLL_CAPTURE_HINT_INCLUDE, + ScrollCaptureTarget target = results.getTopResult(); + assertNotNull("Target not found", target); + assertSame("Result is the wrong View", view5, target.getContainingView()); + assertSame("Result is the wrong callback", callback2, target.getCallback()); + assertEquals("First target hint is incorrect", View.SCROLL_CAPTURE_HINT_INCLUDE, target.getContainingView().getScrollCaptureHint()); } @@ -371,7 +383,7 @@ public class ViewGroupScrollCaptureTest { } void assertCreateScrollCaptureCallbackInternalCount(int count) { - assertEquals("Unexpected number of calls to createScrollCaptureCallackInternal", + assertEquals("Unexpected number of calls to createScrollCaptureCallbackInternal", count, mCreateScrollCaptureCallbackInternalCount); } @@ -385,11 +397,11 @@ public class ViewGroupScrollCaptureTest { @Override public void dispatchScrollCaptureSearch(Rect localVisibleRect, Point windowOffset, - Queue targets) { + Consumer results) { mDispatchScrollCaptureSearchNumCalls++; mDispatchScrollCaptureSearchLastLocalVisibleRect = new Rect(localVisibleRect); mDispatchScrollCaptureSearchLastWindowOffset = new Point(windowOffset); - super.dispatchScrollCaptureSearch(localVisibleRect, windowOffset, targets); + super.dispatchScrollCaptureSearch(localVisibleRect, windowOffset, results); } @Override @@ -401,13 +413,31 @@ public class ViewGroupScrollCaptureTest { } } + static class CallbackStub implements ScrollCaptureCallback { + + @Override + public void onScrollCaptureSearch(@NonNull CancellationSignal signal, + @NonNull Consumer onReady) { + } + + @Override + public void onScrollCaptureStart(@NonNull ScrollCaptureSession session, + @NonNull CancellationSignal signal, @NonNull Runnable onReady) { + } + + @Override + public void onScrollCaptureImageRequest(@NonNull ScrollCaptureSession session, + @NonNull CancellationSignal signal, @NonNull Rect captureArea, + Consumer onComplete) { + } + + @Override + public void onScrollCaptureEnd(@NonNull Runnable onReady) { + } + }; + public static final class MockViewGroup extends ViewGroup { private ScrollCaptureCallback mInternalCallback; - private int mDispatchScrollCaptureSearchNumCalls; - private Rect mDispatchScrollCaptureSearchLastLocalVisibleRect; - private Point mDispatchScrollCaptureSearchLastWindowOffset; - private int mCreateScrollCaptureCallbackInternalCount; - MockViewGroup(Context context) { this(context, /* left */ 0, /* top */0, /* right */ 0, /* bottom */0); @@ -428,16 +458,10 @@ public class ViewGroupScrollCaptureTest { mInternalCallback = internal; } - void assertDispatchScrollCaptureSearchCount(int count) { - assertEquals("Unexpected number of calls to dispatchScrollCaptureSearch", - count, mDispatchScrollCaptureSearchNumCalls); - } - @Override @Nullable public ScrollCaptureCallback createScrollCaptureCallbackInternal(Rect localVisibleRect, Point offsetInWindow) { - mCreateScrollCaptureCallbackInternalCount++; return mInternalCallback; } @@ -445,36 +469,5 @@ public class ViewGroupScrollCaptureTest { protected void onLayout(boolean changed, int l, int t, int r, int b) { // We don't layout this view. } - - void assertDispatchScrollCaptureCount(int count) { - assertEquals(count, mDispatchScrollCaptureSearchNumCalls); - } - - void assertLastDispatchScrollCaptureArgs(Rect localVisibleRect, Point windowOffset) { - assertEquals("arg localVisibleRect to dispatchScrollCaptureCallback was incorrect.", - localVisibleRect, mDispatchScrollCaptureSearchLastLocalVisibleRect); - assertEquals("arg windowOffset to dispatchScrollCaptureCallback was incorrect.", - windowOffset, mDispatchScrollCaptureSearchLastWindowOffset); - } - void assertCreateScrollCaptureCallbackInternalCount(int count) { - assertEquals("Unexpected number of calls to createScrollCaptureCallackInternal", - count, mCreateScrollCaptureCallbackInternalCount); - } - - void reset() { - mDispatchScrollCaptureSearchNumCalls = 0; - mDispatchScrollCaptureSearchLastWindowOffset = null; - mDispatchScrollCaptureSearchLastLocalVisibleRect = null; - mCreateScrollCaptureCallbackInternalCount = 0; - } - - @Override - public void dispatchScrollCaptureSearch(Rect localVisibleRect, Point windowOffset, - Queue targets) { - mDispatchScrollCaptureSearchNumCalls++; - mDispatchScrollCaptureSearchLastLocalVisibleRect = new Rect(localVisibleRect); - mDispatchScrollCaptureSearchLastWindowOffset = new Point(windowOffset); - super.dispatchScrollCaptureSearch(localVisibleRect, windowOffset, targets); - } } } diff --git a/core/tests/coretests/src/android/view/ViewRootImplTest.java b/core/tests/coretests/src/android/view/ViewRootImplTest.java index c67174f0ae1ef..7746bc2e273ad 100644 --- a/core/tests/coretests/src/android/view/ViewRootImplTest.java +++ b/core/tests/coretests/src/android/view/ViewRootImplTest.java @@ -207,7 +207,7 @@ public class ViewRootImplTest { final CountDownLatch latch = new CountDownLatch(1); mViewRootImpl.handleScrollCaptureRequest(new IScrollCaptureCallbacks.Default() { @Override - public void onUnavailable() { + public void onScrollCaptureResponse(ScrollCaptureResponse response) { latch.countDown(); } }); @@ -219,6 +219,37 @@ public class ViewRootImplTest { fail("requestScrollCapture did not respond"); } + /** + * Ensure scroll capture request handles a ViewRootImpl with no view tree. + */ + @Test + public void requestScrollCapture_timeout() { + final View view = new View(mContext); + view.setScrollCaptureCallback(new TestScrollCaptureCallback()); // Does nothing + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + WindowManager.LayoutParams wmlp = + new WindowManager.LayoutParams(TYPE_APPLICATION_OVERLAY); + // Set a fake token to bypass 'is your activity running' check + wmlp.token = new Binder(); + view.setLayoutParams(wmlp); + mViewRootImpl.setView(view, wmlp, null); + }); + + final CountDownLatch latch = new CountDownLatch(1); + mViewRootImpl.setScrollCaptureRequestTimeout(100); + mViewRootImpl.handleScrollCaptureRequest(new IScrollCaptureCallbacks.Default() { + @Override + public void onScrollCaptureResponse(ScrollCaptureResponse response) { + latch.countDown(); + } + }); + try { + if (!latch.await(2500, TimeUnit.MILLISECONDS)) { + fail("requestScrollCapture timeout did not occur"); + } + } catch (InterruptedException e) { /* ignore */ } + } + /** * When window doesn't have focus, keys should be dropped. */ diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/common/SystemWindows.java b/libs/WindowManager/Shell/src/com/android/wm/shell/common/SystemWindows.java index fb70cbe502b0c..4bb8e9b6581f7 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/common/SystemWindows.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/common/SystemWindows.java @@ -39,12 +39,12 @@ import android.view.IWindowSession; import android.view.IWindowSessionCallback; import android.view.InsetsSourceControl; import android.view.InsetsState; +import android.view.ScrollCaptureResponse; import android.view.SurfaceControl; -import android.view.SurfaceSession; import android.view.SurfaceControlViewHost; +import android.view.SurfaceSession; import android.view.View; import android.view.ViewGroup; -import android.view.ViewRootImpl; import android.view.WindowManager; import android.view.WindowlessWindowManager; import android.window.ClientWindowFrames; @@ -371,7 +371,11 @@ public class SystemWindows { @Override public void requestScrollCapture(IScrollCaptureCallbacks callbacks) { try { - callbacks.onUnavailable(); + callbacks.onScrollCaptureResponse( + new ScrollCaptureResponse.Builder() + .setDescription("Not Implemented") + .build()); + } catch (RemoteException ex) { // ignore } diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotController.java b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotController.java index 131fde6b4b56f..805ac7cf1ec9c 100644 --- a/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotController.java +++ b/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotController.java @@ -325,6 +325,7 @@ public class ScreenshotController { attachWindow(); mWindow.setContentView(mScreenshotView); + mScreenshotView.requestApplyInsets(); mScreenshotView.takePartialScreenshot( rect -> takeScreenshotInternal(finisher, rect)); diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ScrollCaptureClient.java b/packages/SystemUI/src/com/android/systemui/screenshot/ScrollCaptureClient.java index dc639dce4951f..70be601c3e09a 100644 --- a/packages/SystemUI/src/com/android/systemui/screenshot/ScrollCaptureClient.java +++ b/packages/SystemUI/src/com/android/systemui/screenshot/ScrollCaptureClient.java @@ -21,24 +21,25 @@ 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.Point; import android.graphics.Rect; import android.hardware.HardwareBuffer; import android.media.Image; import android.media.ImageReader; 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.IWindowManager; +import android.view.ScrollCaptureResponse; import com.android.internal.annotations.VisibleForTesting; -import com.android.internal.view.ScrollCaptureViewSupport; import java.util.function.Consumer; @@ -62,16 +63,19 @@ public class ScrollCaptureClient { */ 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. - * + * Start a session. + * @param sessionConsumer listener to receive the session once active * @param maxPages the capture buffer size expressed as a multiple of the content height */ + // TODO ListenableFuture void start(Consumer sessionConsumer, float maxPages); /** - * Close the connection. + * Close the connection. Must end capture if started to avoid potential unwanted visual + * artifacts. + * + * @see Session#end(Runnable) */ void close(); } @@ -119,6 +123,7 @@ public class ScrollCaptureClient { * @param top the top (y) position of the tile to capture, in content rect space * @param consumer listener to be informed of the result */ + // TODO ListenableFuture void requestTile(int top, Consumer consumer); /** @@ -129,16 +134,31 @@ public class ScrollCaptureClient { */ int getMaxTiles(); + /** + * @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 listener * will be called when the target app is ready to before visible and interactive. */ + // TODO ListenableFuture void end(Runnable listener); } @@ -185,13 +205,13 @@ public class ScrollCaptureClient { + ", taskId=" + taskId + ", consumer=" + consumer + ")"); } mWindowManagerService.requestScrollCapture(displayId, mHostWindowToken, taskId, - new ControllerCallbacks(consumer)); + new ClientCallbacks(consumer)); } catch (RemoteException e) { Log.e(TAG, "Ignored remote exception", e); } } - private static class ControllerCallbacks extends IScrollCaptureCallbacks.Stub implements + private static class ClientCallbacks extends IScrollCaptureCallbacks.Stub implements Connection, Session, IBinder.DeathRecipient { private IScrollCaptureConnection mConnection; @@ -206,46 +226,62 @@ public class ScrollCaptureClient { private int mTileWidth; private Rect mRequestRect; private boolean mStarted; + + private ICancellationSignal mCancellationSignal; + private Rect mWindowBounds; + private Rect mBoundsInWindow; private int mMaxTiles; - private ControllerCallbacks(Consumer connectionConsumer) { + private ClientCallbacks(Consumer connectionConsumer) { mConnectionConsumer = connectionConsumer; } - // IScrollCaptureCallbacks - + @BinderThread @Override - public void onConnected(IScrollCaptureConnection connection, Rect scrollBounds, - Point positionInWindow) throws RemoteException { + public void onScrollCaptureResponse(ScrollCaptureResponse response) throws RemoteException { if (DEBUG_SCROLL) { - Log.d(TAG, "onConnected(connection=" + connection + ", scrollBounds=" + scrollBounds - + ", positionInWindow=" + positionInWindow + ")"); + Log.d(TAG, "onScrollCaptureResponse(response=" + response + ")"); + } + if (response.isConnected()) { + mConnection = response.getConnection(); + mConnection.asBinder().linkToDeath(this, 0); + mWindowBounds = response.getWindowBounds(); + mBoundsInWindow = response.getBoundsInWindow(); + + int pxPerPage = mBoundsInWindow.width() * mBoundsInWindow.height(); + int pxPerTile = min(TILE_SIZE_PX_MAX, (pxPerPage / TILES_PER_PAGE)); + mTileWidth = mBoundsInWindow.width(); + mTileHeight = pxPerTile / mBoundsInWindow.width(); + if (DEBUG_SCROLL) { + Log.d(TAG, "boundsInWindow: " + mBoundsInWindow); + Log.d(TAG, "tile size: " + mTileWidth + "x" + mTileHeight); + Log.d(TAG, "maxHeight: " + (mMaxTiles * mTileHeight) + "px"); + } + mConnectionConsumer.accept(this); } - mConnection = connection; - mConnection.asBinder().linkToDeath(this, 0); - mScrollBounds = scrollBounds; - mConnectionConsumer.accept(this); mConnectionConsumer = null; - - int pxPerPage = mScrollBounds.width() * mScrollBounds.height(); - int pxPerTile = min(TILE_SIZE_PX_MAX, (pxPerPage / TILES_PER_PAGE)); - mTileWidth = mScrollBounds.width(); - mTileHeight = pxPerTile / mScrollBounds.width(); - if (DEBUG_SCROLL) { - Log.d(TAG, "scrollBounds: " + mScrollBounds); - Log.d(TAG, "tile dimen: " + mTileWidth + "x" + mTileHeight); - } } @Override - public void onUnavailable() throws RemoteException { + public void start(Consumer sessionConsumer, float maxPages) { if (DEBUG_SCROLL) { - Log.d(TAG, "onUnavailable"); + Log.d(TAG, "start(sessionConsumer=" + sessionConsumer + "," + + " maxPages=" + maxPages + ")"); + } + mMaxTiles = (int) Math.ceil(maxPages * TILES_PER_PAGE); + mReader = ImageReader.newInstance(mTileWidth, mTileHeight, PixelFormat.RGBA_8888, + mMaxTiles, HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE); + mSessionConsumer = sessionConsumer; + + try { + mCancellationSignal = mConnection.startCapture(mReader.getSurface()); + mStarted = true; + } catch (RemoteException e) { + Log.w(TAG, "Failed to start", e); } - // The targeted app does not support scroll capture - // or the window could not be found... etc etc. } + @BinderThread @Override public void onCaptureStarted() { if (DEBUG_SCROLL) { @@ -256,13 +292,25 @@ public class ScrollCaptureClient { } @Override - public void onCaptureBufferSent(long frameNumber, Rect contentArea) { - Image image = null; - if (frameNumber != ScrollCaptureViewSupport.NO_FRAME_PRODUCED) { - image = mReader.acquireNextImage(); - } + public void requestTile(int top, Consumer consumer) { if (DEBUG_SCROLL) { - Log.d(TAG, "onCaptureBufferSent(frameNumber=" + frameNumber + Log.d(TAG, "requestTile(top=" + top + ", consumer=" + consumer + ")"); + } + cancelPendingRequest(); + mRequestRect = new Rect(0, top, mTileWidth, top + mTileHeight); + mResultConsumer = consumer; + try { + mCancellationSignal = mConnection.requestImage(mRequestRect); + } catch (RemoteException e) { + Log.e(TAG, "Caught remote exception from requestImage", e); + } + } + + @Override + public void onImageRequestCompleted(int flags, Rect contentArea) { + Image image = mReader.acquireLatestImage(); + if (DEBUG_SCROLL) { + Log.d(TAG, "onCaptureBufferSent(flags=" + flags + ", contentArea=" + contentArea + ") image=" + image); } // Save and clear first, since the consumer will likely request the next @@ -272,55 +320,6 @@ public class ScrollCaptureClient { consumer.accept(new CaptureResult(image, mRequestRect, contentArea)); } - @Override - public void onConnectionClosed() { - if (DEBUG_SCROLL) { - 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 - - @Override - public void start(Consumer sessionConsumer, float maxPages) { - if (DEBUG_SCROLL) { - Log.d(TAG, "start(sessionConsumer=" + sessionConsumer + "," - + " maxPages=" + maxPages + ")" - + " [maxHeight: " + (mMaxTiles * mTileHeight) + "px]"); - } - mMaxTiles = (int) Math.ceil(maxPages * TILES_PER_PAGE); - mReader = ImageReader.newInstance(mTileWidth, mTileHeight, PixelFormat.RGBA_8888, - mMaxTiles, HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE); - mSessionConsumer = sessionConsumer; - try { - mConnection.startCapture(mReader.getSurface()); - mStarted = true; - } catch (RemoteException e) { - Log.w(TAG, "Failed to start", e); - } - } - - @Override - public void close() { - end(null); - } - - // ScrollCaptureController.Session - @Override public void end(Runnable listener) { if (DEBUG_SCROLL) { @@ -342,40 +341,37 @@ public class ScrollCaptureClient { } } + @BinderThread @Override - public int getPageHeight() { - return mScrollBounds.height(); - } - - @Override - public int getPageWidth() { - return mScrollBounds.width(); - } - - @Override - public int getTileHeight() { - return mTileHeight; - } - - @Override - public int getMaxTiles() { - return mMaxTiles; - } - - @Override - public void requestTile(int top, Consumer consumer) { - if (DEBUG_SCROLL) { - Log.d(TAG, "requestTile(top=" + top + ", consumer=" + consumer + ")"); + public void onCaptureEnded() { + close(); + if (mShutdownListener != null) { + mShutdownListener.run(); + mShutdownListener = null; } - mRequestRect = new Rect(0, top, mTileWidth, top + mTileHeight); - mResultConsumer = consumer; - try { - mConnection.requestImage(mRequestRect); - } catch (RemoteException e) { - Log.e(TAG, "Caught remote exception from requestImage", e); + } + + @Override + public void close() { + if (mConnection != null) { + try { + mConnection.close(); + } catch (RemoteException e) { + /* ignore */ + } + disconnect(); } } + // Misc + + private void disconnect() { + if (mConnection != null) { + mConnection.asBinder().unlinkToDeath(this, 0); + } + mConnection = null; + } + /** * The process hosting the window went away abruptly! */ @@ -386,5 +382,40 @@ public class ScrollCaptureClient { } disconnect(); } + + @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); + } + + @Override + public int getMaxTiles() { + return mMaxTiles; + } + + private void cancelPendingRequest() { + if (mCancellationSignal != null) { + try { + mCancellationSignal.cancel(); + } catch (RemoteException e) { + /* ignore */ + } + mCancellationSignal = null; + } + } } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/screenshot/FakeScrollCaptureConnection.java b/packages/SystemUI/tests/src/com/android/systemui/screenshot/FakeScrollCaptureConnection.java index a75c39c33f140..9e62a6263a435 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/screenshot/FakeScrollCaptureConnection.java +++ b/packages/SystemUI/tests/src/com/android/systemui/screenshot/FakeScrollCaptureConnection.java @@ -24,6 +24,7 @@ import android.graphics.Paint; import android.graphics.RecordingCanvas; import android.graphics.Rect; import android.graphics.RenderNode; +import android.os.ICancellationSignal; import android.os.RemoteException; import android.view.IScrollCaptureCallbacks; import android.view.IScrollCaptureConnection; @@ -46,7 +47,7 @@ class FakeScrollCaptureConnection extends IScrollCaptureConnection.Stub { } @Override - public void startCapture(Surface surface) { + public ICancellationSignal startCapture(Surface surface) { mSurface = surface; mHwuiContext = new HwuiContext(false, surface); mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); @@ -56,27 +57,28 @@ class FakeScrollCaptureConnection extends IScrollCaptureConnection.Stub { } catch (RemoteException e) { e.rethrowAsRuntimeException(); } + return null; } @Override - public void requestImage(Rect rect) { + public ICancellationSignal 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); + mCallbacks.onImageRequestCompleted(0, rect); } catch (RemoteException e) { e.rethrowAsRuntimeException(); } + return null; } @Override - public void endCapture() { + public ICancellationSignal endCapture() { try { - mCallbacks.onConnectionClosed(); + mCallbacks.onCaptureEnded(); } catch (RemoteException e) { e.rethrowAsRuntimeException(); } finally { @@ -84,6 +86,12 @@ class FakeScrollCaptureConnection extends IScrollCaptureConnection.Stub { mSurface = null; mCallbacks = null; } + return null; + } + + @Override + public void close() throws RemoteException { + } // From android.view.Surface, but issues render requests synchronously with waitForPresent(true) diff --git a/packages/SystemUI/tests/src/com/android/systemui/screenshot/ScrollCaptureClientTest.java b/packages/SystemUI/tests/src/com/android/systemui/screenshot/ScrollCaptureClientTest.java index 580f800fbc440..802b462ec10eb 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/screenshot/ScrollCaptureClientTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/screenshot/ScrollCaptureClientTest.java @@ -29,7 +29,6 @@ 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; @@ -37,6 +36,7 @@ import android.testing.AndroidTestingRunner; import android.view.Display; import android.view.IScrollCaptureCallbacks; import android.view.IWindowManager; +import android.view.ScrollCaptureResponse; import androidx.test.filters.SmallTest; import androidx.test.platform.app.InstrumentationRegistry; @@ -82,10 +82,11 @@ public class ScrollCaptureClientTest extends SysuiTestCase { public void testBasicClientFlow() throws RemoteException { doAnswer((Answer) invocation -> { IScrollCaptureCallbacks cb = invocation.getArgument(3); - cb.onConnected( - new FakeScrollCaptureConnection(cb), - /* scrollBounds */ new Rect(0, 0, 100, 100), - /* positionInWindow */ new Point(0, 0)); + cb.onScrollCaptureResponse(new ScrollCaptureResponse.Builder() + .setBoundsInWindow(new Rect(0, 0, 100, 100)) + .setWindowBounds(new Rect(0, 0, 100, 100)) + .setConnection(new FakeScrollCaptureConnection(cb)) + .build()); return null; }).when(mWm).requestScrollCapture(/* displayId */ anyInt(), /* token */ isNull(), /* taskId */ anyInt(), any(IScrollCaptureCallbacks.class)); diff --git a/packages/SystemUI/tests/src/com/android/systemui/screenshot/ScrollCaptureTest.java b/packages/SystemUI/tests/src/com/android/systemui/screenshot/ScrollCaptureTest.java index 2b3ca7c00a493..6564d588f4eab 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/screenshot/ScrollCaptureTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/screenshot/ScrollCaptureTest.java @@ -19,15 +19,14 @@ package com.android.systemui.screenshot; import static org.junit.Assert.fail; import android.content.Intent; -import android.graphics.Point; import android.graphics.Rect; import android.os.RemoteException; import android.testing.AndroidTestingRunner; import android.util.Log; import android.view.Display; import android.view.IScrollCaptureCallbacks; -import android.view.IScrollCaptureConnection; import android.view.IWindowManager; +import android.view.ScrollCaptureResponse; import android.view.WindowManagerGlobal; import androidx.test.filters.SmallTest; @@ -67,31 +66,27 @@ public class ScrollCaptureTest extends SysuiTestCase { wms.requestScrollCapture(Display.DEFAULT_DISPLAY, null, -1, new IScrollCaptureCallbacks.Stub() { @Override - public void onConnected( - IScrollCaptureConnection connection, Rect scrollBounds, - Point positionInWindow) { - Log.d(TAG, - "client connected: " + connection + "[scrollBounds= " - + scrollBounds + ", " - + "positionInWindow=" + positionInWindow + "]"); + public void onScrollCaptureResponse(ScrollCaptureResponse response) + throws RemoteException { + Log.d(TAG, "onScrollCaptureResponse: " + response); latch.countDown(); } - @Override - public void onUnavailable() { - } - @Override public void onCaptureStarted() { } @Override - public void onCaptureBufferSent(long frameNumber, Rect capturedArea) { + public void onImageRequestCompleted(int i, Rect rect) + throws RemoteException { + } @Override - public void onConnectionClosed() { + public void onCaptureEnded() throws RemoteException { + } + }); } catch (RemoteException e) { Log.e(TAG, "request failed", e); diff --git a/services/core/java/com/android/server/wm/WindowManagerService.java b/services/core/java/com/android/server/wm/WindowManagerService.java index cca85b217a744..c0ccd81c9b159 100644 --- a/services/core/java/com/android/server/wm/WindowManagerService.java +++ b/services/core/java/com/android/server/wm/WindowManagerService.java @@ -253,6 +253,7 @@ import android.view.MagnificationSpec; import android.view.MotionEvent; import android.view.PointerIcon; import android.view.RemoteAnimationAdapter; +import android.view.ScrollCaptureResponse; import android.view.Surface; import android.view.SurfaceControl; import android.view.SurfaceSession; @@ -7159,12 +7160,14 @@ public class WindowManagerService extends IWindowManager.Stub } final long token = Binder.clearCallingIdentity(); try { + ScrollCaptureResponse.Builder responseBuilder = new ScrollCaptureResponse.Builder(); synchronized (mGlobalLock) { DisplayContent dc = mRoot.getDisplayContent(displayId); if (dc == null) { ProtoLog.e(WM_ERROR, "Invalid displayId for requestScrollCapture: %d", displayId); - callbacks.onUnavailable(); + responseBuilder.setDescription(String.format("bad displayId: %d", displayId)); + callbacks.onScrollCaptureResponse(responseBuilder.build()); return; } WindowState topWindow = null; @@ -7173,17 +7176,20 @@ public class WindowManagerService extends IWindowManager.Stub } WindowState targetWindow = dc.findScrollCaptureTargetWindow(topWindow, taskId); if (targetWindow == null) { - callbacks.onUnavailable(); + responseBuilder.setDescription("findScrollCaptureTargetWindow returned null"); + callbacks.onScrollCaptureResponse(responseBuilder.build()); return; } - // Forward to the window for handling. try { + // Forward to the window for handling, which will respond using the callback. targetWindow.mClient.requestScrollCapture(callbacks); } catch (RemoteException e) { ProtoLog.w(WM_ERROR, "requestScrollCapture: caught exception dispatching to window." + "token=%s", targetWindow.mClient.asBinder()); - callbacks.onUnavailable(); + responseBuilder.setWindowTitle(targetWindow.getName()); + responseBuilder.setDescription(String.format("caught exception: %s", e)); + callbacks.onScrollCaptureResponse(responseBuilder.build()); } } } catch (RemoteException e) {