Merge "Scroll Capture Framework" into rvc-dev
diff --git a/core/java/android/view/IScrollCaptureClient.aidl b/core/java/android/view/IScrollCaptureClient.aidl
new file mode 100644
index 0000000..5f135a37
--- /dev/null
+++ b/core/java/android/view/IScrollCaptureClient.aidl
@@ -0,0 +1,49 @@
+/*
+ * 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.graphics.Rect;
+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.
+ *
+ * {@hide}
+ */
+interface IScrollCaptureClient {
+
+ /**
+ * 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.
+ *
+ * @see android.view.ScrollCaptureCallback#onScrollCaptureRequest
+ */
+ oneway void requestImage(in Rect captureArea);
+
+ /**
+ * 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.
+ */
+ oneway void endCapture();
+}
diff --git a/core/java/android/view/IScrollCaptureController.aidl b/core/java/android/view/IScrollCaptureController.aidl
new file mode 100644
index 0000000..8474a00
--- /dev/null
+++ b/core/java/android/view/IScrollCaptureController.aidl
@@ -0,0 +1,63 @@
+/*
+ * 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.graphics.Point;
+import android.graphics.Rect;
+import android.view.Surface;
+
+import android.view.IScrollCaptureClient;
+
+/**
+ * Interface to a controller passed to the {@link IScrollCaptureClient} which provides the client an
+ * asynchronous callback channel for responses.
+ *
+ * {@hide}
+ */
+interface IScrollCaptureController {
+ /**
+ * Scroll capture is available, and a client connect has been returned.
+ *
+ * @param client interface to a ScrollCaptureCallback in the window process
+ * @param scrollAreaInWindow the location of scrolling in global (window) coordinate space
+ */
+ oneway void onClientConnected(in IScrollCaptureClient client, in Rect scrollBounds,
+ in Point positionInWindow);
+
+ /**
+ * Nothing in the window can be scrolled, scroll capture not offered.
+ */
+ oneway void onClientUnavailable();
+
+ /**
+ * Notifies the system that the client has confirmed the request and is ready to begin providing
+ * image requests.
+ */
+ oneway void onCaptureStarted();
+
+ /**
+ * Received a response from a capture request.
+ */
+ oneway void onCaptureBufferSent(long frameNumber, 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.
+ */
+ oneway void onConnectionClosed();
+}
diff --git a/core/java/android/view/IWindow.aidl b/core/java/android/view/IWindow.aidl
index 9f2d36d..e09bf9d 100644
--- a/core/java/android/view/IWindow.aidl
+++ b/core/java/android/view/IWindow.aidl
@@ -21,15 +21,16 @@
import android.graphics.Rect;
import android.os.Bundle;
import android.os.ParcelFileDescriptor;
+import android.util.MergedConfiguration;
+import android.view.DisplayCutout;
import android.view.DragEvent;
+import android.view.InsetsSourceControl;
+import android.view.InsetsState;
+import android.view.IScrollCaptureController;
import android.view.KeyEvent;
import android.view.MotionEvent;
-import android.view.DisplayCutout;
-import android.view.InsetsState;
-import android.view.InsetsSourceControl;
import com.android.internal.os.IResultReceiver;
-import android.util.MergedConfiguration;
/**
* API back to a client window that the Window Manager uses to inform it of
@@ -139,4 +140,11 @@
* Tell the window that it is either gaining or losing pointer capture.
*/
void dispatchPointerCaptureChanged(boolean hasCapture);
+
+ /**
+ * Called when Scroll Capture support is requested for a window.
+ *
+ * @param controller the controller to receive responses
+ */
+ void requestScrollCapture(in IScrollCaptureController controller);
}
diff --git a/core/java/android/view/IWindowManager.aidl b/core/java/android/view/IWindowManager.aidl
index b0bacb9..b3b53f0 100644
--- a/core/java/android/view/IWindowManager.aidl
+++ b/core/java/android/view/IWindowManager.aidl
@@ -42,6 +42,7 @@
import android.view.IDisplayWindowRotationController;
import android.view.IOnKeyguardExitResult;
import android.view.IPinnedStackListener;
+import android.view.IScrollCaptureController;
import android.view.RemoteAnimationAdapter;
import android.view.IRotationWatcher;
import android.view.ISystemGestureExclusionListener;
@@ -749,4 +750,18 @@
* @param flags see definition in SurfaceTracing.cpp
*/
void setLayerTracingFlags(int flags);
+
+ /**
+ * Forwards a scroll capture request to the appropriate window, if available.
+ *
+ * @param displayId the id of the display to target
+ * @param behindClient token for a window, used to filter the search to windows behind it, or
+ * {@code null} to accept a window at any zOrder
+ * @param taskId specifies the id of a task the result must belong to, or -1 to ignore task ids
+ * @param controller the controller to receive results, a call to either
+ * {@link IScrollCaptureController#onClientConnected} or
+ * {@link IScrollCaptureController#onClientUnavailable}.
+ */
+ void requestScrollCapture(int displayId, IBinder behindClient, int taskId,
+ IScrollCaptureController controller);
}
diff --git a/core/java/android/view/ScrollCaptureCallback.java b/core/java/android/view/ScrollCaptureCallback.java
new file mode 100644
index 0000000..e1a4e74
--- /dev/null
+++ b/core/java/android/view/ScrollCaptureCallback.java
@@ -0,0 +1,151 @@
+/*
+ * 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.NonNull;
+import android.annotation.UiThread;
+import android.graphics.Rect;
+
+import java.util.function.Consumer;
+
+/**
+ * A ScrollCaptureCallback is responsible for providing rendered snapshots of scrolling content for
+ * the scroll capture system. A single callback is responsible for providing support to a single
+ * scrolling UI element. At request time, the system will select the best candidate from among all
+ * callbacks registered within the window.
+ * <p>
+ * A callback is assigned to a View using {@link View#setScrollCaptureCallback}, or to the window as
+ * {@link Window#addScrollCaptureCallback}. The point where the callback is registered defines the
+ * frame of reference for the bounds measurements used.
+ * <p>
+ * <b>Terminology</b>
+ * <dl>
+ * <dt>Containing View</dt>
+ * <dd>The view on which this callback is attached, or the root view of the window if the callback
+ * is assigned directly to a window.</dd>
+ *
+ * <dt>Scroll Bounds</dt>
+ * <dd>A rectangle which describes an area within the containing view where scrolling content may
+ * be positioned. This may be the Containing View bounds itself, or any rectangle within.
+ * Requested by {@link #onScrollCaptureSearch}.</dd>
+ *
+ * <dt>Scroll Delta</dt>
+ * <dd>The distance the scroll position has moved since capture started. Implementations are
+ * responsible for tracking changes in vertical scroll position during capture. This is required to
+ * map the capture area to the correct location, given the current scroll position.
+ *
+ * <dt>Capture Area</dt>
+ * <dd>A rectangle which describes the area to capture, relative to scroll bounds. The vertical
+ * position remains relative to the starting scroll position and any movement since ("Scroll Delta")
+ * should be subtracted to locate the correct local position, and scrolled into view as necessary.
+ * </dd>
+ * </dl>
+ *
+ * @see View#setScrollCaptureHint(int)
+ * @see View#setScrollCaptureCallback(ScrollCaptureCallback)
+ * @see Window#addScrollCaptureCallback(ScrollCaptureCallback)
+ *
+ * @hide
+ */
+@UiThread
+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.
+ * <p>
+ * 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.
+ * <p>
+ * 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.
+ * <p>
+ * Responses received after XXXms will be discarded.
+ * <p>
+ * TODO: finalize timeout
+ *
+ * @param onReady consumer for the updated rectangle
+ */
+ void onScrollCaptureSearch(@NonNull Consumer<Rect> onReady);
+
+ /**
+ * Scroll Capture has selected this callback to provide the scrolling image content.
+ * <p>
+ * The onReady signal should be called when ready to begin handling image requests.
+ */
+ void onScrollCaptureStart(@NonNull ScrollCaptureSession session, @NonNull Runnable onReady);
+
+ /**
+ * An image capture has been requested from the scrolling content.
+ * <p>
+ * <code>captureArea</code> contains the bounds of the image requested, relative to the
+ * rectangle provided by {@link ScrollCaptureCallback#onScrollCaptureSearch}, referred to as
+ * {@code scrollBounds}.
+ * here.
+ * <p>
+ * 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.
+ * <p>
+ * 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.
+ * <p>
+ * 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.
+ * <p>
+ * 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).
+ * <p>
+ * Finally, return the resulting {@code captureArea} using
+ * {@link ScrollCaptureSession#notifyBufferSent}.
+ * <p>
+ * 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.
+ * <p>
+ * TODO: finalize timeout
+ * <p>
+ *
+ * @param captureArea the area to capture, a rectangle within {@code scrollBounds}
+ */
+ void onScrollCaptureImageRequest(
+ @NonNull ScrollCaptureSession session, @NonNull Rect captureArea);
+
+ /**
+ * 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.
+ * <p>
+ * 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.
+ * <p>
+ * <code>onReady</code> should be called when the window should be made visible and
+ * interactive. The system will wait up to XXXms for this call before proceeding.
+ * <p>
+ * TODO: finalize timeout
+ *
+ * @param onReady a callback to inform the system that the application has completed any
+ * cleanup and is ready to become visible
+ */
+ void onScrollCaptureEnd(@NonNull Runnable onReady);
+}
+
diff --git a/core/java/android/view/ScrollCaptureClient.java b/core/java/android/view/ScrollCaptureClient.java
new file mode 100644
index 0000000..f163124
--- /dev/null
+++ b/core/java/android/view/ScrollCaptureClient.java
@@ -0,0 +1,312 @@
+/*
+ * 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 java.util.Objects.requireNonNull;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.UiThread;
+import android.annotation.WorkerThread;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.os.Handler;
+import android.os.RemoteException;
+import android.util.CloseGuard;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * A client of the system providing Scroll Capture capability on behalf of a Window.
+ * <p>
+ * An instance is created to wrap the selected {@link ScrollCaptureCallback}.
+ *
+ * @hide
+ */
+public class ScrollCaptureClient extends IScrollCaptureClient.Stub {
+
+ private static final String TAG = "ScrollCaptureClient";
+ private static final int DEFAULT_TIMEOUT = 1000;
+
+ private final Handler mHandler;
+ private ScrollCaptureTarget mSelectedTarget;
+ private int mTimeoutMillis = DEFAULT_TIMEOUT;
+
+ protected Surface mSurface;
+ private IScrollCaptureController mController;
+
+ private final Rect mScrollBounds;
+ private final Point mPositionInWindow;
+ private final CloseGuard mCloseGuard;
+
+ // 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;
+
+ /**
+ * Constructs a ScrollCaptureClient.
+ *
+ * @param selectedTarget the target the client is controlling
+ * @param controller the callbacks to reply to system requests
+ *
+ * @hide
+ */
+ public ScrollCaptureClient(
+ @NonNull ScrollCaptureTarget selectedTarget,
+ @NonNull IScrollCaptureController controller) {
+ requireNonNull(selectedTarget, "<selectedTarget> must non-null");
+ requireNonNull(controller, "<controller> must non-null");
+ final Rect scrollBounds = requireNonNull(selectedTarget.getScrollBounds(),
+ "target.getScrollBounds() must be non-null to construct a client");
+
+ mSelectedTarget = selectedTarget;
+ mHandler = selectedTarget.getContainingView().getHandler();
+ mScrollBounds = new Rect(scrollBounds);
+ mPositionInWindow = new Point(selectedTarget.getPositionInWindow());
+
+ mController = controller;
+ 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();
+ }
+ });
+ }
+
+ @VisibleForTesting
+ public void setTimeoutMillis(int timeoutMillis) {
+ mTimeoutMillis = timeoutMillis;
+ }
+
+ @Nullable
+ @VisibleForTesting
+ public DelayedAction getTimeoutAction() {
+ return mTimeoutAction;
+ }
+
+ private void checkConnected() {
+ if (mSelectedTarget == null || mController == 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 // IScrollCaptureClient
+ @Override
+ public void 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));
+ }
+
+ @UiThread
+ private void onStartCaptureCompleted() {
+ if (cancelTimeout()) {
+ mHandler.post(() -> {
+ try {
+ mController.onCaptureStarted();
+ } catch (RemoteException e) {
+ doShutdown();
+ }
+ });
+ }
+ }
+
+ @UiThread
+ private void onStartCaptureTimeout() {
+ endCapture();
+ }
+
+ @WorkerThread // IScrollCaptureClient
+ @Override
+ public void requestImage(Rect requestRect) {
+ checkConnected();
+ checkStarted();
+ scheduleTimeout(mTimeoutMillis, this::onRequestImageTimeout);
+ // Response is dispatched via ScrollCaptureSession, to onRequestImageCompleted
+ mHandler.post(() -> mSelectedTarget.getCallback().onScrollCaptureImageRequest(
+ mSession, new Rect(requestRect)));
+ }
+
+ @UiThread
+ void onRequestImageCompleted(long frameNumber, Rect capturedArea) {
+ final Rect finalCapturedArea = new Rect(capturedArea);
+ if (cancelTimeout()) {
+ mHandler.post(() -> {
+ try {
+ mController.onCaptureBufferSent(frameNumber, finalCapturedArea);
+ } catch (RemoteException e) {
+ doShutdown();
+ }
+ });
+ }
+ }
+
+ @UiThread
+ private void onRequestImageTimeout() {
+ endCapture();
+ }
+
+ @WorkerThread // IScrollCaptureClient
+ @Override
+ public void endCapture() {
+ if (isStarted()) {
+ scheduleTimeout(mTimeoutMillis, this::onEndCaptureTimeout);
+ mHandler.post(() ->
+ mSelectedTarget.getCallback().onScrollCaptureEnd(this::onEndCaptureCompleted));
+ } else {
+ disconnect();
+ }
+ }
+
+ private boolean isStarted() {
+ return mController != null && mSelectedTarget != null;
+ }
+
+ @UiThread
+ private void onEndCaptureCompleted() { // onEndCaptureCompleted
+ if (cancelTimeout()) {
+ doShutdown();
+ }
+ }
+
+ @UiThread
+ private void onEndCaptureTimeout() {
+ doShutdown();
+ }
+
+
+ private void doShutdown() {
+ try {
+ if (mController != null) {
+ mController.onConnectionClosed();
+ }
+ } catch (RemoteException e) {
+ // Ignore
+ } finally {
+ 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();
+ mSession = null;
+ }
+
+ mSelectedTarget = null;
+ mController = null;
+ }
+
+ /** @return a string representation of the state of this client */
+ public String toString() {
+ return "ScrollCaptureClient{"
+ + ", session=" + mSession
+ + ", selectedTarget=" + mSelectedTarget
+ + ", clientCallbacks=" + mController
+ + "}";
+ }
+
+ 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;
+
+ @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;
+ }
+ return false;
+ }
+
+ /**
+ * 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;
+ }
+ mHandler.removeCallbacksAndMessages(mToken);
+ return true;
+ }
+ }
+}
diff --git a/core/java/android/view/ScrollCaptureSession.java b/core/java/android/view/ScrollCaptureSession.java
new file mode 100644
index 0000000..628e23f
--- /dev/null
+++ b/core/java/android/view/ScrollCaptureSession.java
@@ -0,0 +1,105 @@
+/*
+ * 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.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
+ */
+public class ScrollCaptureSession {
+
+ private final Surface mSurface;
+ private final Rect mScrollBounds;
+ private final Point mPositionInWindow;
+
+ @Nullable
+ private ScrollCaptureClient mClient;
+
+ /** @hide */
+ public ScrollCaptureSession(Surface surface, Rect scrollBounds, Point positionInWindow,
+ ScrollCaptureClient client) {
+ mSurface = surface;
+ mScrollBounds = scrollBounds;
+ mPositionInWindow = positionInWindow;
+ mClient = client;
+ }
+
+ /**
+ * Returns a
+ * <a href="https://source.android.com/devices/graphics/arch-bq-gralloc">BufferQueue</a> in the
+ * form of a {@link Surface} for transfer of image buffers.
+ *
+ * @return the surface for transferring image buffers
+ * @throws IllegalStateException if the session has been closed
+ */
+ @NonNull
+ public Surface getSurface() {
+ return mSurface;
+ }
+
+ /**
+ * Returns the {@code scroll bounds}, as provided by
+ * {@link ScrollCaptureCallback#onScrollCaptureSearch}.
+ *
+ * @return the area of scrolling content within the containing view
+ */
+ @NonNull
+ public Rect getScrollBounds() {
+ return mScrollBounds;
+ }
+
+ /**
+ * Returns the offset of {@code scroll bounds} within the window.
+ *
+ * @return the area of scrolling content within the containing view
+ */
+ @NonNull
+ 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 (mClient != null) {
+ mClient.onRequestImageCompleted(frameNumber, capturedArea);
+ }
+ }
+
+ /**
+ * @hide
+ */
+ public void disconnect() {
+ mClient = null;
+ if (mSurface.isValid()) {
+ mSurface.release();
+ }
+ }
+}
diff --git a/core/java/android/view/ScrollCaptureTarget.java b/core/java/android/view/ScrollCaptureTarget.java
new file mode 100644
index 0000000..f3fcabb
--- /dev/null
+++ b/core/java/android/view/ScrollCaptureTarget.java
@@ -0,0 +1,135 @@
+/*
+ * 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.NonNull;
+import android.annotation.Nullable;
+import android.annotation.UiThread;
+import android.graphics.Matrix;
+import android.graphics.Point;
+import android.graphics.Rect;
+
+import com.android.internal.util.FastMath;
+
+/**
+ * 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;
+ private final ScrollCaptureCallback mCallback;
+ private final Rect mLocalVisibleRect;
+ private final Point mPositionInWindow;
+ private final int mHint;
+ private Rect mScrollBounds;
+
+ 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) {
+ mContainingView = scrollTarget;
+ mHint = mContainingView.getScrollCaptureHint();
+ mCallback = callback;
+ mLocalVisibleRect = localVisibleRect;
+ mPositionInWindow = positionInWindow;
+ }
+
+ /** @return the hint that the {@code containing view} had during the scroll capture search */
+ @View.ScrollCaptureHint
+ public int getHint() {
+ return mHint;
+ }
+
+ /** @return the {@link ScrollCaptureCallback} for this target */
+ @NonNull
+ public ScrollCaptureCallback getCallback() {
+ return mCallback;
+ }
+
+ /** @return the {@code containing view} for this {@link ScrollCaptureCallback callback} */
+ @NonNull
+ public View getContainingView() {
+ return mContainingView;
+ }
+
+ /**
+ * 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.
+ *
+ * @return the visible bounds of the {@code containing view} in view-local coordinates
+ */
+ @NonNull
+ public Rect getLocalVisibleRect() {
+ return mLocalVisibleRect;
+ }
+
+ /** @return the position of the {@code containing view} within the window */
+ @NonNull
+ public Point getPositionInWindow() {
+ return mPositionInWindow;
+ }
+
+ /** @return the {@code scroll bounds} for this {@link ScrollCaptureCallback callback} */
+ @Nullable
+ public Rect getScrollBounds() {
+ return mScrollBounds;
+ }
+
+ /**
+ * Sets the scroll bounds rect to the intersection of provided rect and the current bounds of
+ * the {@code containing view}.
+ */
+ public void setScrollBounds(@Nullable Rect scrollBounds) {
+ mScrollBounds = Rect.copyOrNull(scrollBounds);
+ if (mScrollBounds == null) {
+ return;
+ }
+ if (!mScrollBounds.intersect(0, 0,
+ mContainingView.getWidth(), mContainingView.getHeight())) {
+ mScrollBounds.setEmpty();
+ }
+ }
+
+ private static void zero(float[] pointArray) {
+ pointArray[0] = 0;
+ pointArray[1] = 0;
+ }
+
+ private static void roundIntoPoint(Point pointObj, float[] pointArray) {
+ pointObj.x = FastMath.round(pointArray[0]);
+ pointObj.y = FastMath.round(pointArray[1]);
+ }
+
+ /**
+ * Refresh the value of {@link #mLocalVisibleRect} and {@link #mPositionInWindow} based on the
+ * current state of the {@code containing view}.
+ */
+ @UiThread
+ public void updatePositionInWindow() {
+ mMatrixViewLocalToWindow.reset();
+ mContainingView.transformMatrixToGlobal(mMatrixViewLocalToWindow);
+
+ zero(mTmpFloatArr);
+ mMatrixViewLocalToWindow.mapPoints(mTmpFloatArr);
+ roundIntoPoint(mPositionInWindow, mTmpFloatArr);
+ }
+
+}
diff --git a/core/java/android/view/ScrollCaptureTargetResolver.java b/core/java/android/view/ScrollCaptureTargetResolver.java
new file mode 100644
index 0000000..71e82c5
--- /dev/null
+++ b/core/java/android/view/ScrollCaptureTargetResolver.java
@@ -0,0 +1,387 @@
+/*
+ * 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.
+ * <p>
+ * The rules for selection are (in order):
+ * <ul>
+ * <li>prefer getScrollBounds(): non-empty
+ * <li>prefer View.getScrollCaptureHint == SCROLL_CAPTURE_HINT_INCLUDE
+ * <li>prefer descendants before parents
+ * <li>prefer larger area for getScrollBounds() (clipped to view bounds)
+ * </ul>
+ *
+ * <p>
+ * 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 static final boolean DEBUG = true;
+
+ private final Object mLock = new Object();
+
+ private final Queue<ScrollCaptureTarget> mTargets;
+ private Handler mHandler;
+ private long mTimeLimitMillis;
+
+ private Consumer<ScrollCaptureTarget> 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) {
+ Log.d(TAG, "chooseTarget: " + a + " or " + b);
+ // Nothing plus nothing is still nothing.
+ if (a == null && b == null) {
+ Log.d(TAG, "chooseTarget: (both null) return " + null);
+ return null;
+ }
+ // Prefer non-null.
+ if (a == null || b == null) {
+ ScrollCaptureTarget c = (a == null) ? b : a;
+ Log.d(TAG, "chooseTarget: (other is null) return " + c);
+ 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}.
+ * @param uiHandler the UI thread handler for the view tree
+ * @see #start(long, Consumer)
+ */
+ public ScrollCaptureTargetResolver(Queue<ScrollCaptureTarget> 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).
+ * <p>
+ * 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.elapsedRealtime() >= mDeadlineMillis;
+ if (finish) {
+ System.err.println("We think we're done, or timed out");
+ 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<ScrollCaptureTarget> 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(() -> run(timeLimitMillis, resultConsumer));
+ }
+ }
+
+
+ private void run(long timeLimitMillis, Consumer<ScrollCaptureTarget> resultConsumer) {
+ checkThread();
+
+ mPendingBoundsRequests = mTargets.size();
+ for (ScrollCaptureTarget target : mTargets) {
+ queryTarget(target);
+ }
+ mDeadlineMillis = SystemClock.elapsedRealtime() + mTimeLimitMillis;
+ mHandler.postAtTime(mTimeoutRunnable, mDeadlineMillis);
+ }
+
+ private final Runnable mTimeoutRunnable = new Runnable() {
+ @Override
+ public void run() {
+ 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<Rect>(
+ // 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.elapsedRealtime() >= mDeadlineMillis;
+
+ final View containingView = target.getContainingView();
+ if (!nullOrEmpty(scrollBounds) && containingView.isAggregatedVisible()) {
+ target.updatePositionInWindow();
+ target.setScrollBounds(scrollBounds);
+ supplyResult(target);
+ }
+
+ System.err.println("mPendingBoundsRequests: " + mPendingBoundsRequests);
+ System.err.println("mDeadlineMillis: " + mDeadlineMillis);
+ System.err.println("SystemClock.elapsedRealtime(): " + SystemClock.elapsedRealtime());
+
+ if (!mFinished) {
+ // Reschedule the timeout.
+ System.err.println(
+ "We think we're NOT done yet and will check back at " + mDeadlineMillis);
+ 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;
+ }
+
+ private static int findRelation(@NonNull View a, @NonNull View b) {
+ if (a == b) {
+ return 0;
+ }
+
+ ViewParent parentA = a.getParent();
+ ViewParent parentB = b.getParent();
+
+ while (parentA != null || parentB != null) {
+ if (parentA == parentB) {
+ return 0;
+ }
+ if (parentA == b) {
+ return 1; // A is descendant of B
+ }
+ if (parentB == a) {
+ return -1; // B is descendant of A
+ }
+ if (parentA != null) {
+ parentA = parentA.getParent();
+ }
+ if (parentB != null) {
+ parentB = parentB.getParent();
+ }
+ }
+ return 0;
+ }
+
+ /**
+ * 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.
+ *
+ * @param target the target consumer
+ */
+ static class SingletonConsumer<T> implements Consumer<T> {
+ final AtomicReference<Consumer<T>> mAtomicRef;
+
+ SingletonConsumer(Consumer<T> target) {
+ mAtomicRef = new AtomicReference<>(target);
+ }
+
+ @Override
+ public void accept(T t) {
+ final Consumer<T> 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 8abe72f..f98c1f6 100644
--- a/core/java/android/view/View.java
+++ b/core/java/android/view/View.java
@@ -144,6 +144,7 @@
import com.android.internal.R;
import com.android.internal.util.FrameworkStatsLog;
+import com.android.internal.view.ScrollCaptureInternal;
import com.android.internal.view.TooltipPopup;
import com.android.internal.view.menu.MenuBuilder;
import com.android.internal.widget.ScrollBarUtils;
@@ -167,6 +168,7 @@
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.atomic.AtomicInteger;
import java.util.function.Predicate;
@@ -1311,7 +1313,6 @@
*/
public static final int AUTOFILL_TYPE_LIST = 3;
-
/**
* Autofill type for a field that contains a date, which is represented by a long representing
* the number of milliseconds since the standard base time known as "the epoch", namely
@@ -1441,6 +1442,58 @@
*/
public static final int IMPORTANT_FOR_CONTENT_CAPTURE_NO_EXCLUDE_DESCENDANTS = 0x8;
+ /** {@hide} */
+ @IntDef(flag = true, prefix = {"SCROLL_CAPTURE_HINT_"},
+ value = {
+ SCROLL_CAPTURE_HINT_AUTO,
+ SCROLL_CAPTURE_HINT_EXCLUDE,
+ SCROLL_CAPTURE_HINT_INCLUDE,
+ SCROLL_CAPTURE_HINT_EXCLUDE_DESCENDANTS
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface ScrollCaptureHint {}
+
+ /**
+ * The content of this view will be considered for scroll capture if scrolling is possible.
+ *
+ * @see #getScrollCaptureHint()
+ * @see #setScrollCaptureHint(int)
+ * @hide
+ */
+ public static final int SCROLL_CAPTURE_HINT_AUTO = 0;
+
+ /**
+ * Explicitly exclcude this view as a potential scroll capture target. The system will not
+ * consider it. Mutually exclusive with {@link #SCROLL_CAPTURE_HINT_INCLUDE}, which this flag
+ * takes precedence over.
+ *
+ * @see #getScrollCaptureHint()
+ * @see #setScrollCaptureHint(int)
+ * @hide
+ */
+ public static final int SCROLL_CAPTURE_HINT_EXCLUDE = 0x1;
+
+ /**
+ * Explicitly include this view as a potential scroll capture target. When locating a scroll
+ * capture target, this view will be prioritized before others without this flag. Mutually
+ * exclusive with {@link #SCROLL_CAPTURE_HINT_EXCLUDE}, which takes precedence.
+ *
+ * @see #getScrollCaptureHint()
+ * @see #setScrollCaptureHint(int)
+ * @hide
+ */
+ public static final int SCROLL_CAPTURE_HINT_INCLUDE = 0x2;
+
+ /**
+ * Explicitly exclude all children of this view as potential scroll capture targets. This view
+ * is unaffected. Note: Excluded children are not considered, regardless of {@link
+ * #SCROLL_CAPTURE_HINT_INCLUDE}.
+ *
+ * @see #getScrollCaptureHint()
+ * @see #setScrollCaptureHint(int)
+ * @hide
+ */
+ public static final int SCROLL_CAPTURE_HINT_EXCLUDE_DESCENDANTS = 0x4;
/**
* This view is enabled. Interpretation varies by subclass.
@@ -3430,6 +3483,7 @@
* 11 PFLAG4_CONTENT_CAPTURE_IMPORTANCE_MASK
* 1 PFLAG4_FRAMEWORK_OPTIONAL_FITS_SYSTEM_WINDOWS
* 1 PFLAG4_AUTOFILL_HIDE_HIGHLIGHT
+ * 11 PFLAG4_SCROLL_CAPTURE_HINT_MASK
* |-------|-------|-------|-------|
*/
@@ -3477,6 +3531,15 @@
*/
private static final int PFLAG4_AUTOFILL_HIDE_HIGHLIGHT = 0x200;
+ /**
+ * Shift for the bits in {@link #mPrivateFlags4} related to scroll capture.
+ */
+ static final int PFLAG4_SCROLL_CAPTURE_HINT_SHIFT = 10;
+
+ static final int PFLAG4_SCROLL_CAPTURE_HINT_MASK = (SCROLL_CAPTURE_HINT_INCLUDE
+ | SCROLL_CAPTURE_HINT_EXCLUDE | SCROLL_CAPTURE_HINT_EXCLUDE_DESCENDANTS)
+ << PFLAG4_SCROLL_CAPTURE_HINT_SHIFT;
+
/* End of masks for mPrivateFlags4 */
/** @hide */
@@ -4690,6 +4753,11 @@
* Used to track {@link #mSystemGestureExclusionRects}
*/
public RenderNode.PositionUpdateListener mPositionUpdateListener;
+
+ /**
+ * Allows the application to implement custom scroll capture support.
+ */
+ ScrollCaptureCallback mScrollCaptureCallback;
}
@UnsupportedAppUsage
@@ -5941,6 +6009,9 @@
case R.styleable.View_forceDarkAllowed:
mRenderNode.setForceDarkAllowed(a.getBoolean(attr, true));
break;
+ case R.styleable.View_scrollCaptureHint:
+ setScrollCaptureHint((a.getInt(attr, SCROLL_CAPTURE_HINT_AUTO)));
+ break;
}
}
@@ -29091,6 +29162,11 @@
int mLeashedParentAccessibilityViewId;
/**
+ *
+ */
+ ScrollCaptureInternal mScrollCaptureInternal;
+
+ /**
* Creates a new set of attachment information with the specified
* events handler and thread.
*
@@ -29150,6 +29226,14 @@
return events;
}
+
+ @Nullable
+ ScrollCaptureInternal getScrollCaptureInternal() {
+ if (mScrollCaptureInternal != null) {
+ mScrollCaptureInternal = new ScrollCaptureInternal();
+ }
+ return mScrollCaptureInternal;
+ }
}
/**
@@ -29683,6 +29767,104 @@
}
}
+
+ /**
+ * Returns the current scroll capture hint for this view.
+ *
+ * @return the current scroll capture hint
+ *
+ * @hide
+ */
+ @ScrollCaptureHint
+ public int getScrollCaptureHint() {
+ return (mPrivateFlags4 & PFLAG4_SCROLL_CAPTURE_HINT_MASK)
+ >> PFLAG4_SCROLL_CAPTURE_HINT_SHIFT;
+ }
+
+ /**
+ * Sets the scroll capture hint for this View. These flags affect the search for a potential
+ * scroll capture targets.
+ *
+ * @param hint the scrollCaptureHint flags value to set
+ *
+ * @hide
+ */
+ public void setScrollCaptureHint(@ScrollCaptureHint int hint) {
+ mPrivateFlags4 &= ~PFLAG4_SCROLL_CAPTURE_HINT_MASK;
+ mPrivateFlags4 |= ((hint << PFLAG4_SCROLL_CAPTURE_HINT_SHIFT)
+ & PFLAG4_SCROLL_CAPTURE_HINT_MASK);
+ }
+
+ /**
+ * Sets the callback to receive scroll capture requests. This component is the adapter between
+ * the scroll capture API and application UI code. If no callback is set, the system may provide
+ * an implementation. Any value provided here will take precedence over a system version.
+ * <p>
+ * This view will be ignored when {@link #SCROLL_CAPTURE_HINT_EXCLUDE} is set in its {@link
+ * #setScrollCaptureHint(int) scrollCaptureHint}, regardless whether a callback has been set.
+ * <p>
+ * It is recommended to set the scroll capture hint {@link #SCROLL_CAPTURE_HINT_INCLUDE} when
+ * 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) {
+ getListenerInfo().mScrollCaptureCallback = callback;
+ }
+
+ /** {@hide} */
+ @Nullable
+ public ScrollCaptureCallback createScrollCaptureCallbackInternal(@NonNull Rect localVisibleRect,
+ @NonNull Point windowOffset) {
+ if (mAttachInfo == null) {
+ return null;
+ }
+ if (mAttachInfo.mScrollCaptureInternal == null) {
+ mAttachInfo.mScrollCaptureInternal = new ScrollCaptureInternal();
+ }
+ return mAttachInfo.mScrollCaptureInternal.requestCallback(this, localVisibleRect,
+ windowOffset);
+ }
+
+ /**
+ * 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
+ * flags set by {@link #setScrollCaptureHint}.
+ *
+ * @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
+ *
+ * @throws IllegalStateException if this view is not attached to a window
+ * @hide
+ */
+ public void dispatchScrollCaptureSearch(@NonNull Rect localVisibleRect,
+ @NonNull Point windowOffset, @NonNull Queue<ScrollCaptureTarget> targets) {
+ int hint = getScrollCaptureHint();
+ if ((hint & SCROLL_CAPTURE_HINT_EXCLUDE) != 0) {
+ 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.
+ if (callback == null) {
+ callback = createScrollCaptureCallbackInternal(localVisibleRect, windowOffset);
+ }
+
+ // If found, then add it to the list.
+ if (callback != null) {
+ // 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));
+ }
+ }
+
/**
* Dump all private flags in readable format, useful for documentation and
* sanity checking.
diff --git a/core/java/android/view/ViewGroup.java b/core/java/android/view/ViewGroup.java
index e34e84c..7935eb1 100644
--- a/core/java/android/view/ViewGroup.java
+++ b/core/java/android/view/ViewGroup.java
@@ -40,6 +40,7 @@
import android.graphics.Insets;
import android.graphics.Matrix;
import android.graphics.Paint;
+import android.graphics.Point;
import android.graphics.PointF;
import android.graphics.Rect;
import android.graphics.RectF;
@@ -75,6 +76,7 @@
import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Queue;
import java.util.function.Predicate;
/**
@@ -188,7 +190,16 @@
private PointF mLocalPoint;
// Lazily-created holder for point computations.
- private float[] mTempPoint;
+ private float[] mTempPosition;
+
+ // Lazily-created holder for point computations.
+ private Point mTempPoint;
+
+ // Lazily created Rect for dispatch to children
+ private Rect mTempRect;
+
+ // Lazily created int[2] for dispatch to children
+ private int[] mTempLocation;
// Layout animation
private LayoutAnimationController mLayoutAnimationController;
@@ -1860,7 +1871,7 @@
final float tx = mCurrentDragStartEvent.mX;
final float ty = mCurrentDragStartEvent.mY;
- final float[] point = getTempPoint();
+ final float[] point = getTempLocationF();
point[0] = tx;
point[1] = ty;
transformPointToViewLocal(point, child);
@@ -2932,9 +2943,23 @@
}
}
- private float[] getTempPoint() {
+ private Rect getTempRect() {
+ if (mTempRect == null) {
+ mTempRect = new Rect();
+ }
+ return mTempRect;
+ }
+
+ private float[] getTempLocationF() {
+ if (mTempPosition == null) {
+ mTempPosition = new float[2];
+ }
+ return mTempPosition;
+ }
+
+ private Point getTempPoint() {
if (mTempPoint == null) {
- mTempPoint = new float[2];
+ mTempPoint = new Point();
}
return mTempPoint;
}
@@ -2948,7 +2973,7 @@
@UnsupportedAppUsage
protected boolean isTransformedTouchPointInView(float x, float y, View child,
PointF outLocalPoint) {
- final float[] point = getTempPoint();
+ final float[] point = getTempLocationF();
point[0] = x;
point[1] = y;
transformPointToViewLocal(point, child);
@@ -4568,7 +4593,7 @@
final boolean nonActionable = !child.isClickable() && !child.isLongClickable();
final boolean duplicatesState = (child.mViewFlags & DUPLICATE_PARENT_STATE) != 0;
if (nonActionable || duplicatesState) {
- final float[] point = getTempPoint();
+ final float[] point = getTempLocationF();
point[0] = x;
point[1] = y;
transformPointToViewLocal(point, child);
@@ -7354,6 +7379,97 @@
}
/**
+ * 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.
+ * <p>
+ * 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<ScrollCaptureTarget> 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.
+ *
+ * @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
+ */
+ @Override
+ public void dispatchScrollCaptureSearch(
+ @NonNull Rect localVisibleRect, @NonNull Point windowOffset,
+ @NonNull Queue<ScrollCaptureTarget> targets) {
+
+ // 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);
+ }
+ }
+ }
+
+ /**
* Returns the animation listener to which layout animation events are
* sent.
*
diff --git a/core/java/android/view/ViewRootImpl.java b/core/java/android/view/ViewRootImpl.java
index ed1edc3..68a185d 100644
--- a/core/java/android/view/ViewRootImpl.java
+++ b/core/java/android/view/ViewRootImpl.java
@@ -206,6 +206,7 @@
private static final boolean DEBUG_INPUT_STAGES = false || LOCAL_LOGV;
private static final boolean DEBUG_KEEP_SCREEN_ON = false || LOCAL_LOGV;
private static final boolean DEBUG_CONTENT_CAPTURE = false || LOCAL_LOGV;
+ private static final boolean DEBUG_SCROLL_CAPTURE = false || LOCAL_LOGV;
/**
* Set to false if we do not want to use the multi threaded renderer even though
@@ -653,6 +654,8 @@
private final InsetsController mInsetsController;
private final ImeFocusController mImeFocusController;
+ private ScrollCaptureClient mScrollCaptureClient;
+
/**
* @return {@link ImeFocusController} for this instance.
*/
@@ -661,6 +664,11 @@
return mImeFocusController;
}
+ /** @return The current {@link ScrollCaptureClient} for this instance, if any is active. */
+ @Nullable
+ public ScrollCaptureClient getScrollCaptureClient() {
+ return mScrollCaptureClient;
+ }
private final GestureExclusionTracker mGestureExclusionTracker = new GestureExclusionTracker();
@@ -694,6 +702,8 @@
// draw returns.
private SurfaceControl.Transaction mRtBLASTSyncTransaction = new SurfaceControl.Transaction();
+ private HashSet<ScrollCaptureCallback> mRootScrollCaptureCallbacks;
+
private String mTag = TAG;
public ViewRootImpl(Context context, Display display) {
@@ -4778,6 +4788,7 @@
private static final int MSG_LOCATION_IN_PARENT_DISPLAY_CHANGED = 33;
private static final int MSG_SHOW_INSETS = 34;
private static final int MSG_HIDE_INSETS = 35;
+ private static final int MSG_REQUEST_SCROLL_CAPTURE = 36;
final class ViewRootHandler extends Handler {
@@ -5080,6 +5091,9 @@
case MSG_LOCATION_IN_PARENT_DISPLAY_CHANGED: {
updateLocationInParentDisplay(msg.arg1, msg.arg2);
} break;
+ case MSG_REQUEST_SCROLL_CAPTURE:
+ handleScrollCaptureRequest((IScrollCaptureController) msg.obj);
+ break;
}
}
}
@@ -8789,6 +8803,131 @@
return false;
}
+ /**
+ * Adds a scroll capture callback to this window.
+ *
+ * @param callback the callback to add
+ */
+ public void addScrollCaptureCallback(ScrollCaptureCallback callback) {
+ if (mRootScrollCaptureCallbacks == null) {
+ mRootScrollCaptureCallbacks = new HashSet<>();
+ }
+ mRootScrollCaptureCallbacks.add(callback);
+ }
+
+ /**
+ * Removes a scroll capture callback from this window.
+ *
+ * @param callback the callback to remove
+ */
+ public void removeScrollCaptureCallback(ScrollCaptureCallback callback) {
+ if (mRootScrollCaptureCallbacks != null) {
+ mRootScrollCaptureCallbacks.remove(callback);
+ if (mRootScrollCaptureCallbacks.isEmpty()) {
+ mRootScrollCaptureCallbacks = null;
+ }
+ }
+ }
+
+ /**
+ * Dispatches a scroll capture request to the view hierarchy on the ui thread.
+ *
+ * @param controller the controller to receive replies
+ */
+ public void dispatchScrollCaptureRequest(@NonNull IScrollCaptureController controller) {
+ mHandler.obtainMessage(MSG_REQUEST_SCROLL_CAPTURE, controller).sendToTarget();
+ }
+
+ /**
+ * Collect and include any ScrollCaptureCallback instances registered with the window.
+ *
+ * @see #addScrollCaptureCallback(ScrollCaptureCallback)
+ * @param targets the search queue for targets
+ */
+ private void collectRootScrollCaptureTargets(Queue<ScrollCaptureTarget> targets) {
+ for (ScrollCaptureCallback cb : mRootScrollCaptureCallbacks) {
+ // 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));
+ }
+ }
+
+ /**
+ * 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.
+ * <p>
+ * Either {@link IScrollCaptureController#onClientConnected(IScrollCaptureClient, Rect,
+ * Point)} or {@link IScrollCaptureController#onClientUnavailable()} will be returned
+ * depending on the results of the search.
+ *
+ * @param controller the interface to the system controller
+ * @see ScrollCaptureTargetResolver
+ */
+ private void handleScrollCaptureRequest(@NonNull IScrollCaptureController controller) {
+ LinkedList<ScrollCaptureTarget> targetList = new LinkedList<>();
+
+ // Window (root) level callbacks
+ collectRootScrollCaptureTargets(targetList);
+
+ // Search through View-tree
+ View rootView = getView();
+ Point point = new Point();
+ Rect rect = new Rect(0, 0, rootView.getWidth(), rootView.getHeight());
+ getChildVisibleRect(rootView, rect, point);
+ rootView.dispatchScrollCaptureSearch(rect, point, targetList);
+
+ // No-op path. Scroll capture not offered for this window.
+ if (targetList.isEmpty()) {
+ dispatchScrollCaptureSearchResult(controller, null);
+ return;
+ }
+
+ // 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(controller, selected));
+ }
+
+ /** Called by {@link #handleScrollCaptureRequest} when a result is returned */
+ private void dispatchScrollCaptureSearchResult(
+ @NonNull IScrollCaptureController controller,
+ @Nullable ScrollCaptureTarget selectedTarget) {
+
+ // If timeout or no eligible targets found.
+ if (selectedTarget == null) {
+ try {
+ if (DEBUG_SCROLL_CAPTURE) {
+ Log.d(TAG, "scrollCaptureSearch returned no targets available.");
+ }
+ controller.onClientUnavailable();
+ } catch (RemoteException e) {
+ if (DEBUG_SCROLL_CAPTURE) {
+ Log.w(TAG, "Failed to notify controller of scroll capture search result.", e);
+ }
+ }
+ return;
+ }
+
+ // Create a client instance and return it to the caller
+ mScrollCaptureClient = new ScrollCaptureClient(selectedTarget, controller);
+ try {
+ if (DEBUG_SCROLL_CAPTURE) {
+ Log.d(TAG, "scrollCaptureSearch returning client: " + getScrollCaptureClient());
+ }
+ controller.onClientConnected(
+ mScrollCaptureClient,
+ selectedTarget.getScrollBounds(),
+ selectedTarget.getPositionInWindow());
+ } catch (RemoteException e) {
+ if (DEBUG_SCROLL_CAPTURE) {
+ Log.w(TAG, "Failed to notify controller of scroll capture search result.", e);
+ }
+ mScrollCaptureClient.disconnect();
+ mScrollCaptureClient = null;
+ }
+ }
private void reportNextDraw() {
if (mReportNextDraw == false) {
@@ -9091,6 +9230,13 @@
}
}
+ @Override
+ public void requestScrollCapture(IScrollCaptureController controller) {
+ final ViewRootImpl viewAncestor = mViewAncestor.get();
+ if (viewAncestor != null) {
+ viewAncestor.dispatchScrollCaptureRequest(controller);
+ }
+ }
}
public static final class CalledFromWrongThreadException extends AndroidRuntimeException {
diff --git a/core/java/android/view/Window.java b/core/java/android/view/Window.java
index ae9afaba..b153648 100644
--- a/core/java/android/view/Window.java
+++ b/core/java/android/view/Window.java
@@ -2535,6 +2535,33 @@
return Collections.emptyList();
}
+ /**
+ * System request to begin scroll capture.
+ *
+ * @param controller the controller to receive responses
+ * @hide
+ */
+ public void requestScrollCapture(IScrollCaptureController controller) {
+ }
+
+ /**
+ * Registers a {@link ScrollCaptureCallback} with the root of this window.
+ *
+ * @param callback the callback to add
+ * @hide
+ */
+ public void addScrollCaptureCallback(@NonNull ScrollCaptureCallback callback) {
+ }
+
+ /**
+ * Unregisters a {@link ScrollCaptureCallback} previously registered with this window.
+ *
+ * @param callback the callback to remove
+ * @hide
+ */
+ public void removeScrollCaptureCallback(@NonNull ScrollCaptureCallback callback) {
+ }
+
/** @hide */
public void setTheme(int resId) {
}
diff --git a/core/java/android/view/WindowlessWindowManager.java b/core/java/android/view/WindowlessWindowManager.java
index ec51301..397bce4 100644
--- a/core/java/android/view/WindowlessWindowManager.java
+++ b/core/java/android/view/WindowlessWindowManager.java
@@ -24,7 +24,6 @@
import android.os.RemoteException;
import android.util.Log;
import android.util.MergedConfiguration;
-import android.view.IWindowSession;
import java.util.HashMap;
diff --git a/core/java/com/android/internal/policy/PhoneWindow.java b/core/java/com/android/internal/policy/PhoneWindow.java
index 25c114f..23ba653 100644
--- a/core/java/com/android/internal/policy/PhoneWindow.java
+++ b/core/java/com/android/internal/policy/PhoneWindow.java
@@ -79,6 +79,7 @@
import android.view.ContextThemeWrapper;
import android.view.Gravity;
import android.view.IRotationWatcher.Stub;
+import android.view.IScrollCaptureController;
import android.view.IWindowManager;
import android.view.InputDevice;
import android.view.InputEvent;
@@ -89,6 +90,7 @@
import android.view.Menu;
import android.view.MenuItem;
import android.view.MotionEvent;
+import android.view.ScrollCaptureCallback;
import android.view.SearchEvent;
import android.view.SurfaceHolder.Callback2;
import android.view.View;
@@ -3916,4 +3918,35 @@
: null);
}
}
+
+ /**
+ * System request to begin scroll capture.
+ *
+ * @param controller the controller to receive responses
+ * @hide
+ */
+ @Override
+ public void requestScrollCapture(IScrollCaptureController controller) {
+ getViewRootImpl().dispatchScrollCaptureRequest(controller);
+ }
+
+ /**
+ * Registers a handler providing scrolling capture support for window content.
+ *
+ * @param callback the callback to add
+ */
+ @Override
+ public void addScrollCaptureCallback(@NonNull ScrollCaptureCallback callback) {
+ getViewRootImpl().addScrollCaptureCallback(callback);
+ }
+
+ /**
+ * Unregisters the given {@link ScrollCaptureCallback}.
+ *
+ * @param callback the callback to remove
+ */
+ @Override
+ public void removeScrollCaptureCallback(@NonNull ScrollCaptureCallback callback) {
+ getViewRootImpl().removeScrollCaptureCallback(callback);
+ }
}
diff --git a/core/java/com/android/internal/view/BaseIWindow.java b/core/java/com/android/internal/view/BaseIWindow.java
index 47f094f..7f3eb45 100644
--- a/core/java/com/android/internal/view/BaseIWindow.java
+++ b/core/java/com/android/internal/view/BaseIWindow.java
@@ -26,6 +26,7 @@
import android.util.MergedConfiguration;
import android.view.DisplayCutout;
import android.view.DragEvent;
+import android.view.IScrollCaptureController;
import android.view.IWindow;
import android.view.IWindowSession;
import android.view.InsetsSourceControl;
@@ -169,4 +170,13 @@
@Override
public void dispatchPointerCaptureChanged(boolean hasCapture) {
}
+
+ @Override
+ public void requestScrollCapture(IScrollCaptureController controller) {
+ try {
+ controller.onClientUnavailable();
+ } catch (RemoteException ex) {
+ // ignore
+ }
+ }
}
diff --git a/core/java/com/android/internal/view/ScrollCaptureInternal.java b/core/java/com/android/internal/view/ScrollCaptureInternal.java
new file mode 100644
index 0000000..c589afde
--- /dev/null
+++ b/core/java/com/android/internal/view/ScrollCaptureInternal.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.internal.view;
+
+import android.annotation.Nullable;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.view.ScrollCaptureCallback;
+import android.view.View;
+import android.view.ViewGroup;
+
+/**
+ * Provides built-in framework level Scroll Capture support for standard scrolling Views.
+ */
+public class ScrollCaptureInternal {
+ private static final String TAG = "ScrollCaptureInternal";
+
+ private static final int UP = -1;
+ private static final int DOWN = 1;
+
+ /**
+ * Not a ViewGroup, or cannot scroll according to View APIs.
+ */
+ public static final int TYPE_FIXED = 0;
+
+ /**
+ * Slides a single child view using mScrollX/mScrollY.
+ */
+ public static final int TYPE_SCROLLING = 1;
+
+ /**
+ * Slides child views through the viewport by translating their layout positions with {@link
+ * View#offsetTopAndBottom(int)}. Manages Child view lifecycle, creating as needed and
+ * binding views to data from an adapter. Views are reused whenever possible.
+ */
+ public static final int TYPE_RECYCLING = 2;
+
+ /**
+ * Performs tests on the given View and determines:
+ * 1. If scrolling is possible
+ * 2. What mechanisms are used for scrolling.
+ * <p>
+ * This needs to be fast and not alloc memory. It's called on everything in the tree not marked
+ * as excluded during scroll capture search.
+ */
+ public static int detectScrollingType(View view) {
+ // Must be a ViewGroup
+ if (!(view instanceof ViewGroup)) {
+ return TYPE_FIXED;
+ }
+ // Confirm that it can scroll.
+ if (!(view.canScrollVertically(DOWN) || view.canScrollVertically(UP))) {
+ // Nothing to scroll here, move along.
+ return TYPE_FIXED;
+ }
+ // ScrollViews accept only a single child.
+ if (((ViewGroup) view).getChildCount() > 1) {
+ return TYPE_RECYCLING;
+ }
+ //Because recycling containers don't use scrollY, a non-zero value means Scroll view.
+ if (view.getScrollY() != 0) {
+ return TYPE_SCROLLING;
+ }
+ // Since scrollY cannot be negative, this means a Recycling view.
+ if (view.canScrollVertically(UP)) {
+ return TYPE_RECYCLING;
+ }
+ // canScrollVertically(UP) == false, getScrollY() == 0, getChildCount() == 1.
+
+ // For Recycling containers, this should be a no-op (RecyclerView logs a warning)
+ view.scrollTo(view.getScrollX(), 1);
+
+ // A scrolling container would have moved by 1px.
+ if (view.getScrollY() == 1) {
+ view.scrollTo(view.getScrollX(), 0);
+ return TYPE_SCROLLING;
+ }
+ return TYPE_RECYCLING;
+ }
+
+ /**
+ * Creates a scroll capture callback for the given view if possible.
+ *
+ * @param view the view to capture
+ * @param localVisibleRect the visible area of the given view in local coordinates, as supplied
+ * by the view parent
+ * @param positionInWindow the offset of localVisibleRect within the window
+ *
+ * @return a new callback or null if the View isn't supported
+ */
+ @Nullable
+ public ScrollCaptureCallback requestCallback(View view, Rect localVisibleRect,
+ Point positionInWindow) {
+ // Nothing to see here yet.
+ int i = detectScrollingType(view);
+ switch (i) {
+ case TYPE_SCROLLING:
+ return new ScrollCaptureViewSupport<>((ViewGroup) view,
+ new ScrollViewCaptureHelper());
+ }
+ return null;
+ }
+}
diff --git a/core/java/com/android/internal/view/ScrollCaptureViewHelper.java b/core/java/com/android/internal/view/ScrollCaptureViewHelper.java
new file mode 100644
index 0000000..9f100bd
--- /dev/null
+++ b/core/java/com/android/internal/view/ScrollCaptureViewHelper.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.internal.view;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.graphics.Rect;
+import android.view.View;
+
+interface ScrollCaptureViewHelper<V extends View> {
+ int UP = -1;
+ int DOWN = 1;
+
+ /**
+ * Verifies that the view is still visible and scrollable. If true is returned here, expect a
+ * call to {@link #onComputeScrollBounds(View)} to follow.
+ *
+ * @param view the view being captured
+ * @return true if the callback should respond to a request with scroll bounds
+ */
+ default boolean onAcceptSession(@Nullable V view) {
+ return view != null && view.isVisibleToUser()
+ && (view.canScrollVertically(UP) || view.canScrollVertically(DOWN));
+ }
+
+ /**
+ * Given a scroll capture request for a view, adjust the provided rect to cover the scrollable
+ * content area. The default implementation returns the padded content area of {@code view}.
+ *
+ * @param view the view being captured
+ */
+ default Rect onComputeScrollBounds(@Nullable V view) {
+ return new Rect(view.getPaddingLeft(), view.getPaddingTop(),
+ view.getWidth() - view.getPaddingRight(),
+ view.getHeight() - view.getPaddingBottom());
+ }
+ /**
+ * Adjust the target for capture.
+ * <p>
+ * Do not touch anything that may change layout positions or sizes on screen. Anything else may
+ * be adjusted as long as it can be reversed in {@link #onPrepareForEnd(View)}.
+ *
+ * @param view the view being captured
+ * @param scrollBounds the bounds within {@code view} where content scrolls
+ */
+ void onPrepareForStart(@NonNull V view, Rect scrollBounds);
+
+ /**
+ * Map the request onto the screen.
+ * <p>
+ * Given a rect describing the area to capture, relative to scrollBounds, take actions
+ * necessary to bring the content within the rectangle into the visible area of the view if
+ * needed and return the resulting rectangle describing the position and bounds of the area
+ * which is visible.
+ *
+ * @param scrollBounds the area in which scrolling content moves, local to the {@code containing
+ * view}
+ * @param requestRect the area relative to {@code scrollBounds} which describes the location of
+ * content to capture for the request
+ * @return the visible area within scrollBounds of the requested rectangle, return {@code null}
+ * in the case of an unrecoverable error condition, to abort the capture process
+ */
+ Rect onScrollRequested(@NonNull V view, Rect scrollBounds, Rect requestRect);
+
+ /**
+ * Restore the target after capture.
+ * <p>
+ * Put back anything that was changed in {@link #onPrepareForStart(View, Rect)}.
+ *
+ * @param view the view being captured
+ */
+ void onPrepareForEnd(@NonNull V view);
+}
diff --git a/core/java/com/android/internal/view/ScrollCaptureViewSupport.java b/core/java/com/android/internal/view/ScrollCaptureViewSupport.java
new file mode 100644
index 0000000..4087eda
--- /dev/null
+++ b/core/java/com/android/internal/view/ScrollCaptureViewSupport.java
@@ -0,0 +1,239 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.internal.view;
+
+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.SystemClock;
+import android.util.DisplayMetrics;
+import android.view.ScrollCaptureCallback;
+import android.view.ScrollCaptureSession;
+import android.view.Surface;
+import android.view.View;
+
+import java.lang.ref.WeakReference;
+import java.util.function.Consumer;
+
+/**
+ * Provides a ScrollCaptureCallback implementation for to handle arbitrary View-based scrolling
+ * containers.
+ * <p>
+ * To use this class, supply the target view and an implementation of {@ScrollCaptureViewHelper}
+ * to the callback.
+ *
+ * @param <V> the specific View subclass handled
+ * @hide
+ */
+public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCallback {
+
+ private final WeakReference<V> mWeakView;
+ private final ScrollCaptureViewHelper<V> mViewHelper;
+ private ViewRenderer mRenderer;
+ private Handler mUiHandler;
+ private boolean mStarted;
+ private boolean mEnded;
+
+ static <V extends View> ScrollCaptureCallback createCallback(V view,
+ ScrollCaptureViewHelper<V> impl) {
+ return new ScrollCaptureViewSupport<>(view, impl);
+ }
+
+ ScrollCaptureViewSupport(V containingView, ScrollCaptureViewHelper<V> viewHelper) {
+ mWeakView = new WeakReference<>(containingView);
+ mRenderer = new ViewRenderer();
+ mUiHandler = containingView.getHandler();
+ mViewHelper = viewHelper;
+ }
+
+ // Base implementation of ScrollCaptureCallback
+
+ @Override
+ public final void onScrollCaptureSearch(Consumer<Rect> onReady) {
+ V view = mWeakView.get();
+ mStarted = false;
+ mEnded = false;
+
+ if (view != null && view.isVisibleToUser() && mViewHelper.onAcceptSession(view)) {
+ onReady.accept(mViewHelper.onComputeScrollBounds(view));
+ return;
+ }
+ onReady.accept(null);
+ }
+
+ @Override
+ public final void onScrollCaptureStart(ScrollCaptureSession session, Runnable onReady) {
+ V view = mWeakView.get();
+ mEnded = false;
+ mStarted = true;
+
+ // Note: If somehow the view is already gone or detached, the first call to
+ // {@code onScrollCaptureImageRequest} will return an error and request the session to
+ // end.
+ if (view != null && view.isVisibleToUser()) {
+ mRenderer.setSurface(session.getSurface());
+ mViewHelper.onPrepareForStart(view, session.getScrollBounds());
+ }
+ onReady.run();
+ }
+
+ @Override
+ public final void onScrollCaptureImageRequest(ScrollCaptureSession session, Rect requestRect) {
+ V view = mWeakView.get();
+ if (view == null || !view.isVisibleToUser()) {
+ // Signal to the controller that we have a problem and can't continue.
+ session.notifyBufferSent(0, null);
+ return;
+ }
+ Rect captureArea = mViewHelper.onScrollRequested(view, session.getScrollBounds(),
+ requestRect);
+ mRenderer.renderFrame(view, captureArea, mUiHandler,
+ () -> session.notifyBufferSent(0, captureArea));
+ }
+
+ @Override
+ public final void onScrollCaptureEnd(Runnable onReady) {
+ V view = mWeakView.get();
+ if (mStarted && !mEnded) {
+ mViewHelper.onPrepareForEnd(view);
+ /* empty */
+ mEnded = true;
+ mRenderer.trimMemory();
+ mRenderer.setSurface(null);
+ }
+ onReady.run();
+ }
+
+ /**
+ * Internal helper class which assists in rendering sections of the view hierarchy relative to a
+ * given view. Used by framework implementations of ScrollCaptureHandler to render and dispatch
+ * image requests.
+ */
+ static final class ViewRenderer {
+ // alpha, "reasonable default" from Javadoc
+ private static final float AMBIENT_SHADOW_ALPHA = 0.039f;
+ private static final float SPOT_SHADOW_ALPHA = 0.039f;
+
+ // Default values:
+ // lightX = (screen.width() / 2) - windowLeft
+ // lightY = 0 - windowTop
+ // lightZ = 600dp
+ // lightRadius = 800dp
+ private static final float LIGHT_Z_DP = 400;
+ private static final float LIGHT_RADIUS_DP = 800;
+ private static final String TAG = "ViewRenderer";
+
+ private HardwareRenderer mRenderer;
+ private RenderNode mRootRenderNode;
+ private final RectF mTempRectF = new RectF();
+ private final Rect mSourceRect = new Rect();
+ private final Rect mTempRect = new Rect();
+ private final Matrix mTempMatrix = new Matrix();
+ private final int[] mTempLocation = new int[2];
+ private long mLastRenderedSourceDrawingId = -1;
+
+
+ ViewRenderer() {
+ mRenderer = new HardwareRenderer();
+ mRootRenderNode = new RenderNode("ScrollCaptureRoot");
+ mRenderer.setContentRoot(mRootRenderNode);
+
+ // TODO: Figure out a way to flip this on when we are sure the source window is opaque
+ mRenderer.setOpaque(false);
+ }
+
+ public void setSurface(Surface surface) {
+ mRenderer.setSurface(surface);
+ }
+
+ /**
+ * Cache invalidation check. If the source view is the same as the previous call (which is
+ * mostly always the case, then we can skip setting up lighting on each call (for now)
+ *
+ * @return true if the view changed, false if the view was previously rendered by this class
+ */
+ private boolean updateForView(View source) {
+ if (mLastRenderedSourceDrawingId == source.getUniqueDrawingId()) {
+ return false;
+ }
+ mLastRenderedSourceDrawingId = source.getUniqueDrawingId();
+ return true;
+ }
+
+ // TODO: may need to adjust lightY based on the virtual canvas position to get
+ // consistent shadow positions across the whole capture. Or possibly just
+ // pull lightZ way back to make shadows more uniform.
+ private void setupLighting(View mSource) {
+ mLastRenderedSourceDrawingId = mSource.getUniqueDrawingId();
+ DisplayMetrics metrics = mSource.getResources().getDisplayMetrics();
+ mSource.getLocationOnScreen(mTempLocation);
+ final float lightX = metrics.widthPixels / 2f - mTempLocation[0];
+ final float lightY = metrics.heightPixels - mTempLocation[1];
+ final int lightZ = (int) (LIGHT_Z_DP * metrics.density);
+ final int lightRadius = (int) (LIGHT_RADIUS_DP * metrics.density);
+
+ // Enable shadows for elevation/Z
+ mRenderer.setLightSourceGeometry(lightX, lightY, lightZ, lightRadius);
+ mRenderer.setLightSourceAlpha(AMBIENT_SHADOW_ALPHA, SPOT_SHADOW_ALPHA);
+
+ }
+
+ public void renderFrame(View localReference, Rect sourceRect, Handler handler,
+ Runnable onFrameCommitted) {
+ if (updateForView(localReference)) {
+ setupLighting(localReference);
+ }
+ buildRootDisplayList(localReference, sourceRect);
+ HardwareRenderer.FrameRenderRequest request = mRenderer.createRenderRequest();
+ request.setVsyncTime(SystemClock.elapsedRealtimeNanos());
+ request.setFrameCommitCallback(handler::post, onFrameCommitted);
+ request.setWaitForPresent(true);
+ request.syncAndDraw();
+ }
+
+ public void trimMemory() {
+ mRenderer.clearContent();
+ }
+
+ public void destroy() {
+ mRenderer.destroy();
+ }
+
+ private void transformToRoot(View local, Rect localRect, Rect outRect) {
+ mTempMatrix.reset();
+ local.transformMatrixToGlobal(mTempMatrix);
+ mTempRectF.set(localRect);
+ mTempMatrix.mapRect(mTempRectF);
+ mTempRectF.round(outRect);
+ }
+
+ private void buildRootDisplayList(View source, Rect localSourceRect) {
+ final View captureSource = source.getRootView();
+ transformToRoot(source, localSourceRect, mTempRect);
+ mRootRenderNode.setPosition(0, 0, mTempRect.width(), mTempRect.height());
+ RecordingCanvas canvas = mRootRenderNode.beginRecording(mTempRect.width(),
+ mTempRect.height());
+ canvas.translate(-mTempRect.left, -mTempRect.top);
+ canvas.drawRenderNode(captureSource.updateDisplayListIfDirty());
+ mRootRenderNode.endRecording();
+ }
+ }
+}
diff --git a/core/java/com/android/internal/view/ScrollViewCaptureHelper.java b/core/java/com/android/internal/view/ScrollViewCaptureHelper.java
new file mode 100644
index 0000000..12bd461
--- /dev/null
+++ b/core/java/com/android/internal/view/ScrollViewCaptureHelper.java
@@ -0,0 +1,167 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.internal.view;
+
+import android.annotation.NonNull;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewParent;
+
+/**
+ * ScrollCapture for ScrollView and <i>ScrollView-like</i> ViewGroups.
+ * <p>
+ * Requirements for proper operation:
+ * <ul>
+ * <li>contains at most 1 child.
+ * <li>scrolls to absolute positions with {@link View#scrollTo(int, int)}.
+ * <li>has a finite, known content height and scrolling range
+ * <li>correctly implements {@link View#canScrollVertically(int)}
+ * <li>correctly implements {@link ViewParent#requestChildRectangleOnScreen(View,
+ * Rect, boolean)}
+ * </ul>
+ */
+public class ScrollViewCaptureHelper implements ScrollCaptureViewHelper<ViewGroup> {
+ private int mStartScrollY;
+ private boolean mScrollBarEnabled;
+ private int mOverScrollMode;
+
+ /** @see ScrollCaptureViewHelper#onPrepareForStart(View, Rect) */
+ public void onPrepareForStart(@NonNull ViewGroup view, Rect scrollBounds) {
+ mStartScrollY = view.getScrollY();
+ mOverScrollMode = view.getOverScrollMode();
+ if (mOverScrollMode != View.OVER_SCROLL_NEVER) {
+ view.setOverScrollMode(View.OVER_SCROLL_NEVER);
+ }
+ mScrollBarEnabled = view.isVerticalScrollBarEnabled();
+ if (mScrollBarEnabled) {
+ view.setVerticalScrollBarEnabled(false);
+ }
+ }
+
+ /** @see ScrollCaptureViewHelper#onScrollRequested(View, Rect, Rect) */
+ public Rect onScrollRequested(@NonNull ViewGroup view, Rect scrollBounds, Rect requestRect) {
+ final View contentView = view.getChildAt(0); // returns null, does not throw IOOBE
+ if (contentView == null) {
+ return null;
+ }
+ /*
+ +---------+ <----+ Content [25,25 - 275,1025] (w=250,h=1000)
+ | |
+ ...|.........|... startScrollY=100
+ | |
+ +--+---------+---+ <--+ Container View [0,0 - 300,300] (scrollY=200)
+ | . . |
+ --- | . +-----+ <------+ Scroll Bounds [50,50 - 250,250] (200x200)
+ ^ | . | | . | (Local to Container View, fixed/un-scrolled)
+ | | . | | . |
+ | | . | | . |
+ | | . +-----+ . |
+ | | . . |
+ | +--+---------+---+
+ | | |
+ -+- | +-----+ |
+ | |#####| | <--+ Requested Bounds [0,300 - 200,400] (200x100)
+ | +-----+ | (Local to Scroll Bounds, fixed/un-scrolled)
+ | |
+ +---------+
+
+ Container View (ScrollView) [0,0 - 300,300] (scrollY = 200)
+ \__ Content [25,25 - 275,1025] (250x1000) (contentView)
+ \__ Scroll Bounds[50,50 - 250,250] (w=200,h=200)
+ \__ Requested Bounds[0,300 - 200,400] (200x100)
+ */
+
+ // 0) adjust the requestRect to account for scroll change since start
+ //
+ // Scroll Bounds[50,50 - 250,250] (w=200,h=200)
+ // \__ Requested Bounds[0,200 - 200,300] (200x100)
+
+ // (y-100) (scrollY - mStartScrollY)
+ int scrollDelta = view.getScrollY() - mStartScrollY;
+
+ // 1) Translate request rect to make it relative to container view
+ //
+ // Container View [0,0 - 300,300] (scrollY=200)
+ // \__ Requested Bounds[50,250 - 250,350] (w=250, h=100)
+
+ // (x+50,y+50)
+ Rect requestedContainerBounds = new Rect(requestRect);
+ requestedContainerBounds.offset(0, -scrollDelta);
+ requestedContainerBounds.offset(scrollBounds.left, scrollBounds.top);
+
+ // 2) Translate from container to contentView relative (applying container scrollY)
+ //
+ // Container View [0,0 - 300,300] (scrollY=200)
+ // \__ Content [25,25 - 275,1025] (250x1000) (contentView)
+ // \__ Requested Bounds[25,425 - 200,525] (w=250, h=100)
+
+ // (x-25,y+175) (scrollY - content.top)
+ Rect requestedContentBounds = new Rect(requestedContainerBounds);
+ requestedContentBounds.offset(
+ view.getScrollX() - contentView.getLeft(),
+ view.getScrollY() - contentView.getTop());
+
+
+
+ // requestRect is now local to contentView as requestedContentBounds
+ // contentView (and each parent in turn if possible) will be scrolled
+ // (if necessary) to make all of requestedContent visible, (if possible!)
+ contentView.requestRectangleOnScreen(new Rect(requestedContentBounds), true);
+
+ // update new offset between starting and current scroll position
+ scrollDelta = view.getScrollY() - mStartScrollY;
+
+
+ // TODO: adjust to avoid occlusions/minimize scroll changes
+
+ Point offset = new Point();
+ final Rect capturedRect = new Rect(requestedContentBounds); // empty
+ if (!view.getChildVisibleRect(contentView, capturedRect, offset)) {
+ capturedRect.setEmpty();
+ return capturedRect;
+ }
+ // Transform back from global to content-view local
+ capturedRect.offset(-offset.x, -offset.y);
+
+ // Then back to container view
+ capturedRect.offset(
+ contentView.getLeft() - view.getScrollX(),
+ contentView.getTop() - view.getScrollY());
+
+
+ // And back to relative to scrollBounds
+ capturedRect.offset(-scrollBounds.left, -scrollBounds.top);
+
+ // Apply scrollDelta again to return to make capturedRect relative to scrollBounds at
+ // the scroll position at start of capture.
+ capturedRect.offset(0, scrollDelta);
+ return capturedRect;
+ }
+
+ /** @see ScrollCaptureViewHelper#onPrepareForEnd(View) */
+ public void onPrepareForEnd(@NonNull ViewGroup view) {
+ view.scrollTo(0, mStartScrollY);
+ if (mOverScrollMode != View.OVER_SCROLL_NEVER) {
+ view.setOverScrollMode(mOverScrollMode);
+ }
+ if (mScrollBarEnabled) {
+ view.setVerticalScrollBarEnabled(true);
+ }
+ }
+}
diff --git a/core/res/res/values/attrs.xml b/core/res/res/values/attrs.xml
index 4065a6c..a8d1605 100644
--- a/core/res/res/values/attrs.xml
+++ b/core/res/res/values/attrs.xml
@@ -2527,6 +2527,21 @@
<flag name="noExcludeDescendants" value="0x8" />
</attr>
+ <!-- Hints the Android System whether the this View should be considered a scroll capture target. -->
+ <attr name="scrollCaptureHint">
+ <!-- Let the Android System determine if the view can be a scroll capture target. -->
+ <flag name="auto" value="0" />
+ <!-- Hint the Android System that this view is a likely target. If capable, it will
+ be ranked above other views without this flag. -->
+ <flag name="include" value="0x1" />
+ <!-- Hint the Android System that this view should never be considered a scroll capture
+ target. -->
+ <flag name="exclude" value="0x2" />
+ <!-- Hint the Android System that this view's children should not be examined and should
+ be excluded as a scroll capture target. -->
+ <flag name="excludeDescendants" value="0x4" />
+ </attr>
+
<!-- Boolean that controls whether a view can take focus while in touch mode.
If this is true for a view, that view can gain focus when clicked on, and can keep
focus if another view is clicked on that doesn't have this attribute set to true. -->
diff --git a/core/res/res/values/public.xml b/core/res/res/values/public.xml
index 67d20da..fb887c3 100644
--- a/core/res/res/values/public.xml
+++ b/core/res/res/values/public.xml
@@ -3020,6 +3020,8 @@
<public name="preserveLegacyExternalStorage" />
<public name="mimeGroup" />
<public name="gwpAsanMode" />
+ <!-- @hide -->
+ <public name="scrollCaptureHint" />
</public-group>
<public-group type="drawable" first-id="0x010800b5">
diff --git a/core/tests/coretests/src/android/view/ScrollCaptureClientTest.java b/core/tests/coretests/src/android/view/ScrollCaptureClientTest.java
new file mode 100644
index 0000000..e6ac2d6
--- /dev/null
+++ b/core/tests/coretests/src/android/view/ScrollCaptureClientTest.java
@@ -0,0 +1,309 @@
+/*
+ * 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.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;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+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 org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+import org.mockito.stubbing.Answer;
+
+/**
+ * Tests of {@link ScrollCaptureClient}.
+ */
+@SuppressWarnings("UnnecessaryLocalVariable")
+@RunWith(AndroidJUnit4.class)
+public class ScrollCaptureClientTest {
+
+ 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 Handler mHandler;
+ private ScrollCaptureTarget mTarget1;
+
+ @Mock
+ private Surface mSurface;
+ @Mock
+ private IScrollCaptureController mClientCallbacks;
+ @Mock
+ private View mMockView1;
+ @Mock
+ private ScrollCaptureCallback mCallback1;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+ mHandler = new Handler(getTargetContext().getMainLooper());
+
+ 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);
+ ScrollCaptureClient.DelayedAction delayed =
+ new ScrollCaptureClient.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);
+ ScrollCaptureClient.DelayedAction delayed =
+ new ScrollCaptureClient.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);
+ ScrollCaptureClient.DelayedAction delayed =
+ new ScrollCaptureClient.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();
+ }
+
+ /** Test creating a client with valid info */
+ @Test
+ public void testConstruction() {
+ new ScrollCaptureClient(mTarget1, mClientCallbacks);
+ }
+
+ /** Test creating a client fails if arguments are not valid. */
+ @Test
+ public void testConstruction_requiresScrollBounds() {
+ try {
+ mTarget1.setScrollBounds(null);
+ new ScrollCaptureClient(mTarget1, mClientCallbacks);
+ fail("An exception was expected.");
+ } catch (RuntimeException ex) {
+ // Ignore, expected.
+ }
+ }
+
+ @SuppressWarnings("SameParameterValue")
+ private static Answer<Void> runRunnable(int arg) {
+ return invocation -> {
+ Runnable r = invocation.getArgument(arg);
+ r.run();
+ return null;
+ };
+ }
+
+ @SuppressWarnings("SameParameterValue")
+ private static Answer<Void> reportBufferSent(int sessionArg, long frameNum, Rect capturedArea) {
+ return invocation -> {
+ ScrollCaptureSession session = invocation.getArgument(sessionArg);
+ session.notifyBufferSent(frameNum, capturedArea);
+ return null;
+ };
+ }
+
+ /** @see ScrollCaptureClient#startCapture(Surface) */
+ @Test
+ public void testStartCapture() throws Exception {
+ final ScrollCaptureClient client = new ScrollCaptureClient(mTarget1, mClientCallbacks);
+
+ // Have the session start accepted immediately
+ doAnswer(runRunnable(1)).when(mCallback1)
+ .onScrollCaptureStart(any(ScrollCaptureSession.class), any(Runnable.class));
+ client.startCapture(mSurface);
+ getInstrumentation().waitForIdleSync();
+
+ verify(mCallback1, times(1))
+ .onScrollCaptureStart(any(ScrollCaptureSession.class), any(Runnable.class));
+ verify(mClientCallbacks, times(1)).onCaptureStarted();
+ verifyNoMoreInteractions(mClientCallbacks);
+ }
+
+ @Test
+ public void testStartCaptureTimeout() throws Exception {
+ final ScrollCaptureClient client = new ScrollCaptureClient(mTarget1, mClientCallbacks);
+ client.startCapture(mSurface);
+
+ // Force timeout to fire
+ client.getTimeoutAction().timeoutNow();
+
+ getInstrumentation().waitForIdleSync();
+ verify(mCallback1, times(1)).onScrollCaptureEnd(any(Runnable.class));
+ }
+
+ private void startClient(ScrollCaptureClient client) throws Exception {
+ doAnswer(runRunnable(1)).when(mCallback1)
+ .onScrollCaptureStart(any(ScrollCaptureSession.class), any(Runnable.class));
+ client.startCapture(mSurface);
+ getInstrumentation().waitForIdleSync();
+ reset(mCallback1, mClientCallbacks);
+ }
+
+ /** @see ScrollCaptureClient#requestImage(Rect) */
+ @Test
+ public void testRequestImage() throws Exception {
+ final ScrollCaptureClient client = new ScrollCaptureClient(mTarget1, mClientCallbacks);
+ startClient(client);
+
+ // 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));
+
+ // Make the inbound binder call
+ client.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(mClientCallbacks, times(1)).onCaptureBufferSent(eq(1L), eq(new Rect(1, 2, 3, 4)));
+
+ verifyNoMoreInteractions(mCallback1, mClientCallbacks);
+ }
+
+ @Test
+ public void testRequestImageTimeout() throws Exception {
+ final ScrollCaptureClient client = new ScrollCaptureClient(mTarget1, mClientCallbacks);
+ startClient(client);
+
+ // Make the inbound binder call
+ client.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)));
+
+ // Force timeout to fire
+ client.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, mClientCallbacks);
+ }
+
+ /** @see ScrollCaptureClient#endCapture() */
+ @Test
+ public void testEndCapture() throws Exception {
+ final ScrollCaptureClient client = new ScrollCaptureClient(mTarget1, mClientCallbacks);
+ startClient(client);
+
+ // Stub the callback to complete the request immediately
+ doAnswer(runRunnable(0))
+ .when(mCallback1)
+ .onScrollCaptureEnd(any(Runnable.class));
+
+ // Make the inbound binder call
+ client.endCapture();
+
+ // Wait for handler thread dispatch
+ getInstrumentation().waitForIdleSync();
+ verify(mCallback1, times(1)).onScrollCaptureEnd(any(Runnable.class));
+
+ // Wait for binder thread dispatch
+ getInstrumentation().waitForIdleSync();
+ verify(mClientCallbacks, times(1)).onConnectionClosed();
+
+ verifyNoMoreInteractions(mCallback1, mClientCallbacks);
+ }
+
+ @Test
+ public void testEndCaptureTimeout() throws Exception {
+ final ScrollCaptureClient client = new ScrollCaptureClient(mTarget1, mClientCallbacks);
+ startClient(client);
+
+ // Make the inbound binder call
+ client.endCapture();
+
+ // Wait for handler thread dispatch
+ getInstrumentation().waitForIdleSync();
+ verify(mCallback1, times(1)).onScrollCaptureEnd(any(Runnable.class));
+
+ // Force timeout to fire
+ client.getTimeoutAction().timeoutNow();
+
+ // Wait for binder thread dispatch
+ getInstrumentation().waitForIdleSync();
+ verify(mClientCallbacks, times(1)).onConnectionClosed();
+
+ verifyNoMoreInteractions(mCallback1, mClientCallbacks);
+ }
+}
diff --git a/core/tests/coretests/src/android/view/ScrollCaptureTargetResolverTest.java b/core/tests/coretests/src/android/view/ScrollCaptureTargetResolverTest.java
new file mode 100644
index 0000000..8b21b8e
--- /dev/null
+++ b/core/tests/coretests/src/android/view/ScrollCaptureTargetResolverTest.java
@@ -0,0 +1,498 @@
+/*
+ * 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<ScrollCaptureTarget> 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<ScrollCaptureTarget> 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<ScrollCaptureTarget> 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<ScrollCaptureTarget> 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<ScrollCaptureTarget> 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<ScrollCaptureTarget> 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<ScrollCaptureTarget> 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<ScrollCaptureTarget> 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<ScrollCaptureTarget> {
+ 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<Rect> 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/ViewGroupScrollCaptureTest.java b/core/tests/coretests/src/android/view/ViewGroupScrollCaptureTest.java
new file mode 100644
index 0000000..3af0533
--- /dev/null
+++ b/core/tests/coretests/src/android/view/ViewGroupScrollCaptureTest.java
@@ -0,0 +1,480 @@
+/*
+ * 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.platform.app.InstrumentationRegistry.getInstrumentation;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.testng.AssertJUnit.assertSame;
+
+import android.annotation.Nullable;
+import android.content.Context;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.platform.test.annotations.Presubmit;
+
+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;
+
+/**
+ * Exercises Scroll Capture search in {@link ViewGroup}.
+ */
+@Presubmit
+@SmallTest
+@FlakyTest(detail = "promote once confirmed flake-free")
+@RunWith(MockitoJUnitRunner.class)
+public class ViewGroupScrollCaptureTest {
+
+ @Mock
+ ScrollCaptureCallback mMockCallback;
+ @Mock
+ ScrollCaptureCallback mMockCallback2;
+
+ /** Make sure the hint flags are saved and loaded correctly. */
+ @Test
+ public void testSetScrollCaptureHint() throws Exception {
+ final Context context = getInstrumentation().getContext();
+ final MockViewGroup viewGroup = new MockViewGroup(context);
+
+ assertNotNull(viewGroup);
+ assertEquals("Default scroll capture hint flags should be [SCROLL_CAPTURE_HINT_AUTO]",
+ ViewGroup.SCROLL_CAPTURE_HINT_AUTO, viewGroup.getScrollCaptureHint());
+
+ viewGroup.setScrollCaptureHint(View.SCROLL_CAPTURE_HINT_INCLUDE);
+ assertEquals("The scroll capture hint was not stored correctly.",
+ ViewGroup.SCROLL_CAPTURE_HINT_INCLUDE, viewGroup.getScrollCaptureHint());
+
+ viewGroup.setScrollCaptureHint(ViewGroup.SCROLL_CAPTURE_HINT_EXCLUDE);
+ assertEquals("The scroll capture hint was not stored correctly.",
+ ViewGroup.SCROLL_CAPTURE_HINT_EXCLUDE, viewGroup.getScrollCaptureHint());
+
+ viewGroup.setScrollCaptureHint(ViewGroup.SCROLL_CAPTURE_HINT_EXCLUDE_DESCENDANTS);
+ assertEquals("The scroll capture hint was not stored correctly.",
+ ViewGroup.SCROLL_CAPTURE_HINT_EXCLUDE_DESCENDANTS,
+ viewGroup.getScrollCaptureHint());
+
+ viewGroup.setScrollCaptureHint(ViewGroup.SCROLL_CAPTURE_HINT_INCLUDE
+ | ViewGroup.SCROLL_CAPTURE_HINT_EXCLUDE_DESCENDANTS);
+ assertEquals("The scroll capture hint was not stored correctly.",
+ ViewGroup.SCROLL_CAPTURE_HINT_INCLUDE
+ | ViewGroup.SCROLL_CAPTURE_HINT_EXCLUDE_DESCENDANTS,
+ viewGroup.getScrollCaptureHint());
+
+ viewGroup.setScrollCaptureHint(ViewGroup.SCROLL_CAPTURE_HINT_EXCLUDE
+ | ViewGroup.SCROLL_CAPTURE_HINT_EXCLUDE_DESCENDANTS);
+ assertEquals("The scroll capture hint was not stored correctly.",
+ ViewGroup.SCROLL_CAPTURE_HINT_EXCLUDE
+ | ViewGroup.SCROLL_CAPTURE_HINT_EXCLUDE_DESCENDANTS,
+ viewGroup.getScrollCaptureHint());
+ }
+
+ /**
+ * Ensure a ViewGroup with 'scrollCaptureHint=auto', but no ScrollCaptureCallback set dispatches
+ * correctly. Verifies that the framework helper is called. Verifies a that non-null callback
+ * return results in an expected target in the results.
+ */
+ @MediumTest
+ @Test
+ public void testDispatchScrollCaptureSearch_noCallback_hintAuto() throws Exception {
+ final Context context = getInstrumentation().getContext();
+ final MockViewGroup viewGroup = new MockViewGroup(context, 0, 0, 200, 200);
+
+ // When system internal scroll capture is requested, this callback is returned.
+ viewGroup.setScrollCaptureCallbackInternalForTest(mMockCallback);
+
+ Rect localVisibleRect = new Rect(0, 0, 200, 200);
+ Point windowOffset = new Point();
+ LinkedList<ScrollCaptureTarget> targetList = new LinkedList<>();
+
+ // Dispatch
+ viewGroup.dispatchScrollCaptureSearch(localVisibleRect, windowOffset, targetList);
+
+ // Verify the system checked for fallback support
+ viewGroup.assertDispatchScrollCaptureCount(1);
+ viewGroup.assertLastDispatchScrollCaptureArgs(localVisibleRect, windowOffset);
+
+ // Verify the target is as expected.
+ assertEquals(1, targetList.size());
+ ScrollCaptureTarget target = targetList.get(0);
+ assertSame("Target has the wrong callback", mMockCallback, target.getCallback());
+ assertSame("Target has the wrong View", viewGroup, target.getContainingView());
+ assertEquals("Target hint is incorrect", View.SCROLL_CAPTURE_HINT_AUTO,
+ target.getContainingView().getScrollCaptureHint());
+ }
+
+ /**
+ * Ensure a ViewGroup with 'scrollCaptureHint=exclude' is ignored. The Framework helper is
+ * stubbed to return a callback. Verifies that the framework helper is not called (because of
+ * exclude), and no scroll capture target is added to the results.
+ */
+ @MediumTest
+ @Test
+ public void testDispatchScrollCaptureSearch_noCallback_hintExclude() throws Exception {
+ final Context context = getInstrumentation().getContext();
+ final MockViewGroup viewGroup =
+ new MockViewGroup(context, 0, 0, 200, 200, View.SCROLL_CAPTURE_HINT_EXCLUDE);
+
+ // When system internal scroll capture is requested, this callback is returned.
+ viewGroup.setScrollCaptureCallbackInternalForTest(mMockCallback);
+
+ Rect localVisibleRect = new Rect(0, 0, 200, 200);
+ Point windowOffset = new Point();
+ LinkedList<ScrollCaptureTarget> targetList = new LinkedList<>();
+
+ // Dispatch
+ viewGroup.dispatchScrollCaptureSearch(localVisibleRect, windowOffset, targetList);
+
+ // Verify the results.
+ assertEquals("Target list size should be zero.", 0, targetList.size());
+ }
+
+ /**
+ * Ensure that a ViewGroup with 'scrollCaptureHint=auto', and a scroll capture callback set
+ * dispatches as expected. Also verifies that the system fallback support is not called, and the
+ * the returned target is constructed correctly.
+ */
+ @MediumTest
+ @Test
+ public void testDispatchScrollCaptureSearch_withCallback_hintAuto() throws Exception {
+ final Context context = getInstrumentation().getContext();
+ MockViewGroup viewGroup = new MockViewGroup(context, 0, 0, 200, 200);
+
+ // With an already provided scroll capture callback
+ viewGroup.setScrollCaptureCallback(mMockCallback);
+
+ // When system internal scroll capture is requested, this callback is returned.
+ viewGroup.setScrollCaptureCallbackInternalForTest(mMockCallback);
+
+ Rect localVisibleRect = new Rect(0, 0, 200, 200);
+ Point windowOffset = new Point();
+ LinkedList<ScrollCaptureTarget> targetList = new LinkedList<>();
+
+ // 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);
+
+ // Verify the target is as expected.
+ assertEquals(1, targetList.size());
+ ScrollCaptureTarget target = targetList.get(0);
+ assertSame("Target has the wrong callback", mMockCallback, target.getCallback());
+ assertSame("Target has the wrong View", viewGroup, target.getContainingView());
+ assertEquals("Target hint is incorrect", View.SCROLL_CAPTURE_HINT_AUTO,
+ target.getContainingView().getScrollCaptureHint());
+ }
+
+ /**
+ * Ensure a ViewGroup with a callback set, but 'scrollCaptureHint=exclude' is ignored. The
+ * exclude flag takes precedence. Verifies that the framework helper is not called (because of
+ * exclude, and a callback being set), and no scroll capture target is added to the results.
+ */
+ @MediumTest
+ @Test
+ public void testDispatchScrollCaptureSearch_withCallback_hintExclude() throws Exception {
+ final Context context = getInstrumentation().getContext();
+ MockViewGroup viewGroup =
+ new MockViewGroup(context, 0, 0, 200, 200, View.SCROLL_CAPTURE_HINT_EXCLUDE);
+ // With an already provided scroll capture callback
+ viewGroup.setScrollCaptureCallback(mMockCallback);
+
+ Rect localVisibleRect = new Rect(0, 0, 200, 200);
+ Point windowOffset = new Point();
+ LinkedList<ScrollCaptureTarget> targetList = new LinkedList<>();
+
+ // 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);
+
+ // Has callback, but hint=excluded, so excluded.
+ assertTrue(targetList.isEmpty());
+ }
+
+ /**
+ * Test scroll capture search dispatch to child views.
+ * <p>
+ * Verifies computation of child visible bounds.
+ * TODO: with scrollX / scrollY, split up into discrete tests
+ */
+ @MediumTest
+ @Test
+ public void testDispatchScrollCaptureSearch_toChildren() throws Exception {
+ final Context context = getInstrumentation().getContext();
+ final MockViewGroup viewGroup = new MockViewGroup(context, 0, 0, 200, 200);
+
+ Rect localVisibleRect = new Rect(25, 50, 175, 150);
+ Point windowOffset = new Point(0, 0);
+
+ // visible area
+ // |<- l=25, |
+ // | r=175 ->|
+ // +--------------------------+
+ // | view1 (0, 0, 200, 25) |
+ // +---------------+----------+
+ // | | |
+ // | view2 | view4 | --+
+ // | (0, 25, | (inv) | | visible area
+ // | 150, 100)| | |
+ // +---------------+----------+ | t=50, b=150
+ // | view3 | view5 | |
+ // | (0, 100 |(150, 100 | --+
+ // | 200, 200) | 200, 200)|
+ // | | |
+ // | | |
+ // +---------------+----------+ (200,200)
+
+ // View 1 is clipped and not visible.
+ final MockView view1 = new MockView(context, 0, 0, 200, 25);
+ viewGroup.addView(view1);
+
+ // View 2 is partially visible.
+ final MockView view2 = new MockView(context, 0, 25, 150, 100);
+ viewGroup.addView(view2);
+
+ // View 3 is partially visible.
+ // 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);
+ 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.
+ 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.setScrollCaptureHint(View.SCROLL_CAPTURE_HINT_INCLUDE);
+ viewGroup.addView(view5);
+
+ // Where targets are added
+ final LinkedList<ScrollCaptureTarget> targetList = new LinkedList<>();
+
+ // Dispatch to the ViewGroup
+ viewGroup.dispatchScrollCaptureSearch(localVisibleRect, windowOffset, targetList);
+
+ // View 1 is entirely clipped by the parent and not visible, dispatch
+ // skips this view entirely.
+ view1.assertDispatchScrollCaptureSearchCount(0);
+ view1.assertCreateScrollCaptureCallbackInternalCount(0);
+
+ // View 2, verify the computed localVisibleRect and windowOffset are correctly transformed
+ // to the child coordinate space
+ view2.assertDispatchScrollCaptureSearchCount(1);
+ view2.assertDispatchScrollCaptureSearchLastArgs(
+ new Rect(25, 25, 150, 75), new Point(0, 25));
+ // No callback set, so the framework is asked for support
+ view2.assertCreateScrollCaptureCallbackInternalCount(1);
+
+ // View 3, verify the computed localVisibleRect and windowOffset are correctly transformed
+ // to the child coordinate space
+ view3.assertDispatchScrollCaptureSearchCount(1);
+ view3.assertDispatchScrollCaptureSearchLastArgs(
+ new Rect(25, 0, 175, 50), new Point(0, 100));
+ // No callback set, so the framework is asked for support
+ view3.assertCreateScrollCaptureCallbackInternalCount(1);
+
+ // view4 is invisible, so it should be skipped entirely.
+ view4.assertDispatchScrollCaptureSearchCount(0);
+ view4.assertCreateScrollCaptureCallbackInternalCount(0);
+
+ // view5 is partially visible
+ view5.assertDispatchScrollCaptureSearchCount(1);
+ view5.assertDispatchScrollCaptureSearchLastArgs(
+ new Rect(0, 0, 25, 50), new Point(150, 100));
+ // view5 has a callback set on it, so internal framework support should not be consulted.
+ view5.assertCreateScrollCaptureCallbackInternalCount(0);
+
+ // 2 views should have been returned, view3 & view5
+ assertEquals(2, targetList.size());
+
+ 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,
+ target.getContainingView().getScrollCaptureHint());
+ }
+
+ public static final class MockView extends View {
+ private ScrollCaptureCallback mInternalCallback;
+
+ private int mDispatchScrollCaptureSearchNumCalls;
+ private Rect mDispatchScrollCaptureSearchLastLocalVisibleRect;
+ private Point mDispatchScrollCaptureSearchLastWindowOffset;
+ private int mCreateScrollCaptureCallbackInternalCount;
+
+ MockView(Context context) {
+ this(context, /* left */ 0, /* top */0, /* right */ 0, /* bottom */0);
+ }
+
+ MockView(Context context, int left, int top, int right, int bottom) {
+ this(context, left, top, right, bottom, View.VISIBLE);
+ }
+
+ MockView(Context context, int left, int top, int right, int bottom, int visibility) {
+ super(context);
+ setVisibility(visibility);
+ setFrame(left, top, right, bottom);
+ }
+
+ public void setScrollCaptureCallbackInternalForTest(ScrollCaptureCallback internal) {
+ mInternalCallback = internal;
+ }
+
+ void assertDispatchScrollCaptureSearchCount(int count) {
+ assertEquals("Unexpected number of calls to dispatchScrollCaptureSearch",
+ count, mDispatchScrollCaptureSearchNumCalls);
+ }
+
+ void assertDispatchScrollCaptureSearchLastArgs(Rect localVisibleRect, Point windowOffset) {
+ assertEquals("arg localVisibleRect was incorrect.",
+ localVisibleRect, mDispatchScrollCaptureSearchLastLocalVisibleRect);
+ assertEquals("arg windowOffset 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<ScrollCaptureTarget> targets) {
+ mDispatchScrollCaptureSearchNumCalls++;
+ mDispatchScrollCaptureSearchLastLocalVisibleRect = new Rect(localVisibleRect);
+ mDispatchScrollCaptureSearchLastWindowOffset = new Point(windowOffset);
+ super.dispatchScrollCaptureSearch(localVisibleRect, windowOffset, targets);
+ }
+
+ @Override
+ @Nullable
+ public ScrollCaptureCallback createScrollCaptureCallbackInternal(Rect localVisibleRect,
+ Point offsetInWindow) {
+ mCreateScrollCaptureCallbackInternalCount++;
+ return mInternalCallback;
+ }
+ }
+
+ 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);
+ }
+
+ MockViewGroup(Context context, int left, int top, int right, int bottom) {
+ this(context, left, top, right, bottom, View.SCROLL_CAPTURE_HINT_AUTO);
+ }
+
+ MockViewGroup(Context context, int left, int top, int right, int bottom,
+ int scrollCaptureHint) {
+ super(context);
+ setScrollCaptureHint(scrollCaptureHint);
+ setFrame(left, top, right, bottom);
+ }
+
+ public void setScrollCaptureCallbackInternalForTest(ScrollCaptureCallback internal) {
+ 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;
+ }
+
+ @Override
+ 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<ScrollCaptureTarget> targets) {
+ mDispatchScrollCaptureSearchNumCalls++;
+ mDispatchScrollCaptureSearchLastLocalVisibleRect = new Rect(localVisibleRect);
+ mDispatchScrollCaptureSearchLastWindowOffset = new Point(windowOffset);
+ super.dispatchScrollCaptureSearch(localVisibleRect, windowOffset, targets);
+ }
+ }
+}
diff --git a/core/tests/coretests/src/com/android/internal/view/ScrollViewCaptureHelperTest.java b/core/tests/coretests/src/com/android/internal/view/ScrollViewCaptureHelperTest.java
new file mode 100644
index 0000000..63a68e9
--- /dev/null
+++ b/core/tests/coretests/src/com/android/internal/view/ScrollViewCaptureHelperTest.java
@@ -0,0 +1,352 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.internal.view;
+
+import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
+import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
+import static android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
+import static android.view.WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
+
+import static androidx.test.InstrumentationRegistry.getContext;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import android.graphics.Color;
+import android.graphics.PixelFormat;
+import android.graphics.Rect;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.WindowManager;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import android.widget.ScrollView;
+import android.widget.TextView;
+
+import androidx.test.annotation.UiThreadTest;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.util.Random;
+
+public class ScrollViewCaptureHelperTest {
+
+ private FrameLayout mParent;
+ private ScrollView mTarget;
+ private LinearLayout mContent;
+ private WindowManager mWm;
+
+ private WindowManager.LayoutParams mWindowLayoutParams;
+
+ private static final int CHILD_VIEWS = 12;
+ public static final int CHILD_VIEW_HEIGHT = 300;
+
+ private static final int WINDOW_WIDTH = 800;
+ private static final int WINDOW_HEIGHT = 1200;
+
+ private static final int CAPTURE_HEIGHT = 600;
+
+ private Random mRandom;
+
+ private static float sDensity;
+
+ @BeforeClass
+ public static void setUpClass() {
+ sDensity = getContext().getResources().getDisplayMetrics().density;
+ }
+
+ @Before
+ @UiThreadTest
+ public void setUp() {
+ mRandom = new Random();
+ mParent = new FrameLayout(getContext());
+
+ mTarget = new ScrollView(getContext());
+ mParent.addView(mTarget, new ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT));
+
+ mContent = new LinearLayout(getContext());
+ mContent.setOrientation(LinearLayout.VERTICAL);
+ mTarget.addView(mContent, new ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT));
+
+ for (int i = 0; i < CHILD_VIEWS; i++) {
+ TextView view = new TextView(getContext());
+ view.setText("Child #" + i);
+ view.setTextColor(Color.WHITE);
+ view.setTextSize(30f);
+ view.setBackgroundColor(Color.rgb(mRandom.nextFloat(), mRandom.nextFloat(),
+ mRandom.nextFloat()));
+ mContent.addView(view, new ViewGroup.LayoutParams(MATCH_PARENT, CHILD_VIEW_HEIGHT));
+ }
+
+ // Window -> Parent -> Target -> Content
+
+ mWm = getContext().getSystemService(WindowManager.class);
+
+ // Setup the window that we are going to use
+ mWindowLayoutParams = new WindowManager.LayoutParams(WINDOW_WIDTH, WINDOW_HEIGHT,
+ TYPE_APPLICATION_OVERLAY, FLAG_NOT_TOUCHABLE, PixelFormat.OPAQUE);
+ mWindowLayoutParams.setTitle("ScrollViewCaptureHelper");
+ mWindowLayoutParams.gravity = Gravity.CENTER;
+ mWm.addView(mParent, mWindowLayoutParams);
+ }
+
+ @After
+ @UiThreadTest
+ public void tearDown() {
+ mWm.removeViewImmediate(mParent);
+ }
+
+ @Test
+ @UiThreadTest
+ public void onPrepareForStart() {
+ ScrollViewCaptureHelper svc = new ScrollViewCaptureHelper();
+ Rect scrollBounds = svc.onComputeScrollBounds(mTarget);
+ svc.onPrepareForStart(mTarget, scrollBounds);
+ }
+
+ static void assertEmpty(Rect r) {
+ if (r != null && !r.isEmpty()) {
+ fail("Not true that " + r + " is empty");
+ }
+ }
+
+ static void assertContains(Rect parent, Rect child) {
+ if (!parent.contains(child)) {
+ fail("Not true that " + parent + " contains " + child);
+ }
+ }
+
+ static void assertRectEquals(Rect parent, Rect child) {
+ if (!parent.equals(child)) {
+ fail("Not true that " + parent + " is equal to " + child);
+ }
+ }
+
+ static Rect getVisibleRect(View v) {
+ Rect r = new Rect(0, 0, v.getWidth(), v.getHeight());
+ v.getLocalVisibleRect(r);
+ return r;
+ }
+
+
+ static int assertScrollToY(View v, int scrollY) {
+ v.scrollTo(0, scrollY);
+ int dest = v.getScrollY();
+ assertEquals(scrollY, dest);
+ return scrollY;
+ }
+
+
+ static void assertCapturedAreaCompletelyVisible(int startScrollY, Rect requestRect,
+ Rect localVisibleNow) {
+ Rect captured = new Rect(localVisibleNow);
+ captured.offset(0, -startScrollY); // make relative
+
+ if (!captured.contains(requestRect)) {
+ fail("Not true that all of " + requestRect + " is contained by " + captured);
+ }
+ }
+ static void assertCapturedAreaPartiallyVisible(int startScrollY, Rect requestRect,
+ Rect localVisibleNow) {
+ Rect captured = new Rect(localVisibleNow);
+ captured.offset(0, -startScrollY); // make relative
+
+ if (!Rect.intersects(captured, requestRect)) {
+ fail("Not true that any of " + requestRect + " intersects " + captured);
+ }
+ }
+
+ @Test
+ @UiThreadTest
+ public void onScrollRequested_up_fromTop() {
+ final int startScrollY = assertScrollToY(mTarget, 0);
+
+ ScrollViewCaptureHelper svc = new ScrollViewCaptureHelper();
+ Rect scrollBounds = svc.onComputeScrollBounds(mTarget);
+ svc.onPrepareForStart(mTarget, scrollBounds);
+
+ assertTrue(scrollBounds.height() > CAPTURE_HEIGHT);
+
+ Rect request = new Rect(0, -CAPTURE_HEIGHT, scrollBounds.width(), 0);
+
+ Rect result = svc.onScrollRequested(mTarget, scrollBounds, request);
+
+ // The result is an empty rectangle and no scrolling, since it
+ // is not possible to physically scroll further up to make the
+ // requested area visible at all (it doesn't exist).
+ assertEmpty(result);
+ }
+
+ @Test
+ @UiThreadTest
+ public void onScrollRequested_down_fromTop() {
+ final int startScrollY = assertScrollToY(mTarget, 0);
+
+
+ ScrollViewCaptureHelper svc = new ScrollViewCaptureHelper();
+ Rect scrollBounds = svc.onComputeScrollBounds(mTarget);
+ svc.onPrepareForStart(mTarget, scrollBounds);
+
+ assertTrue(scrollBounds.height() > CAPTURE_HEIGHT);
+
+ // Capture between y = +1200 to +1500 pixels BELOW current top
+ Rect request = new Rect(0, WINDOW_HEIGHT, scrollBounds.width(),
+ WINDOW_HEIGHT + CAPTURE_HEIGHT);
+
+ Rect result = svc.onScrollRequested(mTarget, scrollBounds, request);
+ assertRectEquals(request, result);
+
+ assertCapturedAreaCompletelyVisible(startScrollY, request, getVisibleRect(mContent));
+ }
+
+
+ @Test
+ @UiThreadTest
+ public void onScrollRequested_up_fromMiddle() {
+ final int startScrollY = assertScrollToY(mTarget, WINDOW_HEIGHT);
+
+ ScrollViewCaptureHelper svc = new ScrollViewCaptureHelper();
+ Rect scrollBounds = svc.onComputeScrollBounds(mTarget);
+ svc.onPrepareForStart(mTarget, scrollBounds);
+
+ Rect request = new Rect(0, -CAPTURE_HEIGHT, scrollBounds.width(), 0);
+
+
+ Rect result = svc.onScrollRequested(mTarget, scrollBounds, request);
+
+ assertRectEquals(request, result);
+
+ assertCapturedAreaCompletelyVisible(startScrollY, request, getVisibleRect(mContent));
+ }
+
+ @Test
+ @UiThreadTest
+ public void onScrollRequested_down_fromMiddle() {
+ final int startScrollY = assertScrollToY(mTarget, WINDOW_HEIGHT);
+
+ ScrollViewCaptureHelper svc = new ScrollViewCaptureHelper();
+ Rect scrollBounds = svc.onComputeScrollBounds(mTarget);
+ svc.onPrepareForStart(mTarget, scrollBounds);
+
+ Rect request = new Rect(0, WINDOW_HEIGHT, scrollBounds.width(),
+ WINDOW_HEIGHT + CAPTURE_HEIGHT);
+
+ Rect result = svc.onScrollRequested(mTarget, scrollBounds, request);
+ assertRectEquals(request, result);
+
+ assertCapturedAreaCompletelyVisible(startScrollY, request, getVisibleRect(mContent));
+ }
+
+ @Test
+ @UiThreadTest
+ public void onScrollRequested_up_fromBottom() {
+ final int startScrollY = assertScrollToY(mTarget, WINDOW_HEIGHT * 2);
+
+ ScrollViewCaptureHelper svc = new ScrollViewCaptureHelper();
+ Rect scrollBounds = svc.onComputeScrollBounds(mTarget);
+ svc.onPrepareForStart(mTarget, scrollBounds);
+
+ Rect request = new Rect(0, -CAPTURE_HEIGHT, scrollBounds.width(), 0);
+
+ Rect result = svc.onScrollRequested(mTarget, scrollBounds, request);
+ assertRectEquals(request, result);
+
+ assertCapturedAreaCompletelyVisible(startScrollY, request, getVisibleRect(mContent));
+ }
+
+ @Test
+ @UiThreadTest
+ public void onScrollRequested_down_fromBottom() {
+ final int startScrollY = assertScrollToY(mTarget, WINDOW_HEIGHT * 2);
+
+ ScrollViewCaptureHelper svc = new ScrollViewCaptureHelper();
+ Rect scrollBounds = svc.onComputeScrollBounds(mTarget);
+ svc.onPrepareForStart(mTarget, scrollBounds);
+
+ Rect request = new Rect(0, WINDOW_HEIGHT, scrollBounds.width(),
+ WINDOW_HEIGHT + CAPTURE_HEIGHT);
+
+ Rect result = svc.onScrollRequested(mTarget, scrollBounds, request);
+
+ // The result is an empty rectangle and no scrolling, since it
+ // is not possible to physically scroll further down to make the
+ // requested area visible at all (it doesn't exist).
+ assertEmpty(result);
+ }
+
+ @Test
+ @UiThreadTest
+ public void onScrollRequested_offTopEdge() {
+ final int startScrollY = assertScrollToY(mTarget, 0);
+
+ ScrollViewCaptureHelper svc = new ScrollViewCaptureHelper();
+ Rect scrollBounds = svc.onComputeScrollBounds(mTarget);
+ svc.onPrepareForStart(mTarget, scrollBounds);
+
+ // Create a request which lands halfway off the top of the content
+ //from -1500 to -900, (starting at 1200 = -300 to +300 within the content)
+ int top = 0;
+ Rect request = new Rect(
+ 0, top - (CAPTURE_HEIGHT / 2),
+ scrollBounds.width(), top + (CAPTURE_HEIGHT / 2));
+
+ Rect result = svc.onScrollRequested(mTarget, scrollBounds, request);
+ // The result is a partial result
+ Rect expectedResult = new Rect(request);
+ expectedResult.top += 300; // top half clipped
+ assertRectEquals(expectedResult, result);
+ assertCapturedAreaPartiallyVisible(startScrollY, request, getVisibleRect(mContent));
+ }
+
+ @Test
+ @UiThreadTest
+ public void onScrollRequested_offBottomEdge() {
+ final int startScrollY = assertScrollToY(mTarget, WINDOW_HEIGHT * 2); // 2400
+
+ ScrollViewCaptureHelper svc = new ScrollViewCaptureHelper();
+ Rect scrollBounds = svc.onComputeScrollBounds(mTarget);
+ svc.onPrepareForStart(mTarget, scrollBounds);
+
+ // Create a request which lands halfway off the bottom of the content
+ //from 600 to to 1200, (starting at 2400 = 3000 to 3600 within the content)
+
+ int bottom = WINDOW_HEIGHT;
+ Rect request = new Rect(
+ 0, bottom - (CAPTURE_HEIGHT / 2),
+ scrollBounds.width(), bottom + (CAPTURE_HEIGHT / 2));
+
+ Rect result = svc.onScrollRequested(mTarget, scrollBounds, request);
+
+ Rect expectedResult = new Rect(request);
+ expectedResult.bottom -= 300; // bottom half clipped
+ assertRectEquals(expectedResult, result);
+ assertCapturedAreaPartiallyVisible(startScrollY, request, getVisibleRect(mContent));
+
+ }
+
+ @Test
+ @UiThreadTest
+ public void onPrepareForEnd() {
+ ScrollViewCaptureHelper svc = new ScrollViewCaptureHelper();
+ svc.onPrepareForEnd(mTarget);
+ }
+}
diff --git a/data/etc/services.core.protolog.json b/data/etc/services.core.protolog.json
index 18086ec..c5ac451 100644
--- a/data/etc/services.core.protolog.json
+++ b/data/etc/services.core.protolog.json
@@ -283,6 +283,12 @@
"group": "WM_DEBUG_APP_TRANSITIONS",
"at": "com\/android\/server\/wm\/ActivityRecord.java"
},
+ "-1517908912": {
+ "message": "requestScrollCapture: caught exception dispatching to window.token=%s",
+ "level": "WARN",
+ "group": "WM_ERROR",
+ "at": "com\/android\/server\/wm\/WindowManagerService.java"
+ },
"-1515151503": {
"message": ">>> OPEN TRANSACTION removeReplacedWindows",
"level": "INFO",
@@ -1441,6 +1447,12 @@
"group": "WM_DEBUG_RECENTS_ANIMATIONS",
"at": "com\/android\/server\/wm\/RecentsAnimation.java"
},
+ "646981048": {
+ "message": "Invalid displayId for requestScrollCapture: %d",
+ "level": "ERROR",
+ "group": "WM_ERROR",
+ "at": "com\/android\/server\/wm\/WindowManagerService.java"
+ },
"662572728": {
"message": "Attempted to add a toast window with bad token %s. Aborting.",
"level": "WARN",
@@ -1597,6 +1609,12 @@
"group": "WM_ERROR",
"at": "com\/android\/server\/wm\/WindowManagerService.java"
},
+ "1046922686": {
+ "message": "requestScrollCapture: caught exception dispatching callback: %s",
+ "level": "WARN",
+ "group": "WM_ERROR",
+ "at": "com\/android\/server\/wm\/WindowManagerService.java"
+ },
"1051545910": {
"message": "Exit animation finished in %s: remove=%b",
"level": "VERBOSE",
diff --git a/packages/SystemUI/src/com/android/systemui/screenshot/ScrollCaptureController.java b/packages/SystemUI/src/com/android/systemui/screenshot/ScrollCaptureController.java
new file mode 100644
index 0000000..5ced40c
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/screenshot/ScrollCaptureController.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.screenshot;
+
+import android.os.IBinder;
+import android.view.IWindowManager;
+
+import javax.inject.Inject;
+
+/**
+ * Stub
+ */
+public class ScrollCaptureController {
+
+ public static final int STATUS_A = 0;
+ public static final int STATUS_B = 1;
+
+ private final IWindowManager mWindowManagerService;
+ private StatusListener mListener;
+
+ /**
+ *
+ * @param windowManagerService
+ */
+ @Inject
+ public ScrollCaptureController(IWindowManager windowManagerService) {
+ mWindowManagerService = windowManagerService;
+ }
+
+ interface StatusListener {
+ void onScrollCaptureStatus(boolean available);
+ }
+
+ /**
+ *
+ * @param window
+ * @param listener
+ */
+ public void getStatus(IBinder window, StatusListener listener) {
+ mListener = listener;
+// try {
+// mWindowManagerService.requestScrollCapture(window, new ClientCallbacks());
+// } catch (RemoteException e) {
+// }
+ }
+
+}
diff --git a/packages/SystemUI/src/com/android/systemui/wm/SystemWindows.java b/packages/SystemUI/src/com/android/systemui/wm/SystemWindows.java
index e5da603..899aabb 100644
--- a/packages/SystemUI/src/com/android/systemui/wm/SystemWindows.java
+++ b/packages/SystemUI/src/com/android/systemui/wm/SystemWindows.java
@@ -32,6 +32,7 @@
import android.view.Display;
import android.view.DisplayCutout;
import android.view.DragEvent;
+import android.view.IScrollCaptureController;
import android.view.IWindow;
import android.view.IWindowManager;
import android.view.IWindowSession;
@@ -352,5 +353,14 @@
@Override
public void dispatchPointerCaptureChanged(boolean hasCapture) {}
+
+ @Override
+ public void requestScrollCapture(IScrollCaptureController controller) {
+ try {
+ controller.onClientUnavailable();
+ } catch (RemoteException ex) {
+ // ignore
+ }
+ }
}
}
diff --git a/services/core/java/com/android/server/wm/DisplayContent.java b/services/core/java/com/android/server/wm/DisplayContent.java
index a47cdc6..b01acca 100644
--- a/services/core/java/com/android/server/wm/DisplayContent.java
+++ b/services/core/java/com/android/server/wm/DisplayContent.java
@@ -5450,6 +5450,46 @@
return mWmService.mDisplayManagerInternal.getDisplayPosition(getDisplayId());
}
+ /**
+ * Locates the appropriate target window for scroll capture. The search progresses top to
+ * bottom.
+ * If {@code searchBehind} is non-null, the search will only consider windows behind this one.
+ * If a valid taskId is specified, the target window must belong to the given task.
+ *
+ * @param searchBehind a window used to filter the search to windows behind it, or null to begin
+ * the search at the top window of the display
+ * @param taskId specifies the id of a task the result must belong to or
+ * {@link android.app.ActivityTaskManager#INVALID_TASK_ID INVALID_TASK_ID}
+ * to match any window
+ * @return the located window or null if none could be found matching criteria
+ */
+ @Nullable
+ WindowState findScrollCaptureTargetWindow(@Nullable WindowState searchBehind, int taskId) {
+ return getWindow(new Predicate<WindowState>() {
+ boolean behindTopWindow = (searchBehind == null); // optional filter
+ @Override
+ public boolean test(WindowState nextWindow) {
+ // Skip through all windows until we pass topWindow (if specified)
+ if (!behindTopWindow) {
+ if (nextWindow == searchBehind) {
+ behindTopWindow = true;
+ }
+ return false; /* continue */
+ }
+ if (taskId != INVALID_TASK_ID) {
+ Task task = nextWindow.getTask();
+ if (task == null || !task.isTaskId(taskId)) {
+ return false; /* continue */
+ }
+ }
+ if (!nextWindow.canReceiveKeys()) {
+ return false; /* continue */
+ }
+ return true; /* stop */
+ }
+ });
+ }
+
class RemoteInsetsControlTarget implements InsetsControlTarget {
private final IDisplayWindowInsetsController mRemoteInsetsController;
diff --git a/services/core/java/com/android/server/wm/WindowManagerService.java b/services/core/java/com/android/server/wm/WindowManagerService.java
index f55a1b3..a501414 100644
--- a/services/core/java/com/android/server/wm/WindowManagerService.java
+++ b/services/core/java/com/android/server/wm/WindowManagerService.java
@@ -219,6 +219,7 @@
import android.view.IPinnedStackListener;
import android.view.IRecentsAnimationRunner;
import android.view.IRotationWatcher;
+import android.view.IScrollCaptureController;
import android.view.ISystemGestureExclusionListener;
import android.view.IWallpaperVisibilityListener;
import android.view.IWindow;
@@ -6837,6 +6838,58 @@
}
}
+ /**
+ * Forwards a scroll capture request to the appropriate window, if available.
+ *
+ * @param displayId the display for the request
+ * @param behindClient token for a window, used to filter the search to windows behind it
+ * @param taskId specifies the id of a task the result must belong to or -1 to ignore task ids
+ * @param controller the controller to receive results; a call to either
+ * {@link IScrollCaptureController#onClientConnected} or
+ * {@link IScrollCaptureController#onClientUnavailable}.
+ */
+ public void requestScrollCapture(int displayId, @Nullable IBinder behindClient, int taskId,
+ IScrollCaptureController controller) {
+ if (!checkCallingPermission(READ_FRAME_BUFFER, "requestScrollCapture()")) {
+ throw new SecurityException("Requires READ_FRAME_BUFFER permission");
+ }
+ final long token = Binder.clearCallingIdentity();
+ try {
+ synchronized (mGlobalLock) {
+ DisplayContent dc = mRoot.getDisplayContent(displayId);
+ if (dc == null) {
+ ProtoLog.e(WM_ERROR,
+ "Invalid displayId for requestScrollCapture: %d", displayId);
+ controller.onClientUnavailable();
+ return;
+ }
+ WindowState topWindow = null;
+ if (behindClient != null) {
+ topWindow = windowForClientLocked(null, behindClient, /* throwOnError*/ true);
+ }
+ WindowState targetWindow = dc.findScrollCaptureTargetWindow(topWindow, taskId);
+ if (targetWindow == null) {
+ controller.onClientUnavailable();
+ return;
+ }
+ // Forward to the window for handling.
+ try {
+ targetWindow.mClient.requestScrollCapture(controller);
+ } catch (RemoteException e) {
+ ProtoLog.w(WM_ERROR,
+ "requestScrollCapture: caught exception dispatching to window."
+ + "token=%s", targetWindow.mClient.asBinder());
+ controller.onClientUnavailable();
+ }
+ }
+ } catch (RemoteException e) {
+ ProtoLog.w(WM_ERROR,
+ "requestScrollCapture: caught exception dispatching callback: %s", e);
+ } finally {
+ Binder.restoreCallingIdentity(token);
+ }
+ }
+
@Override
public void dontOverrideDisplayInfo(int displayId) {
final long token = Binder.clearCallingIdentity();
diff --git a/services/tests/wmtests/src/com/android/server/wm/DisplayContentTests.java b/services/tests/wmtests/src/com/android/server/wm/DisplayContentTests.java
index daff149..80fcf2e 100644
--- a/services/tests/wmtests/src/com/android/server/wm/DisplayContentTests.java
+++ b/services/tests/wmtests/src/com/android/server/wm/DisplayContentTests.java
@@ -41,6 +41,7 @@
import static android.view.WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
import static android.view.WindowManager.LayoutParams.TYPE_BASE_APPLICATION;
import static android.view.WindowManager.LayoutParams.TYPE_NOTIFICATION_SHADE;
+import static android.view.WindowManager.LayoutParams.TYPE_SCREENSHOT;
import static android.view.WindowManager.LayoutParams.TYPE_STATUS_BAR;
import static android.view.WindowManager.LayoutParams.TYPE_VOICE_INTERACTION;
import static android.view.WindowManager.LayoutParams.TYPE_WALLPAPER;
@@ -74,6 +75,7 @@
import static org.mockito.ArgumentMatchers.eq;
import android.annotation.SuppressLint;
+import android.app.ActivityTaskManager;
import android.app.WindowConfiguration;
import android.content.res.Configuration;
import android.graphics.Rect;
@@ -1207,6 +1209,31 @@
assertNull(taskDisplayArea.getOrCreateRootHomeTask());
}
+ @Test
+ public void testFindScrollCaptureTargetWindow_behindWindow() {
+ DisplayContent display = createNewDisplay();
+ ActivityStack stack = createTaskStackOnDisplay(display);
+ Task task = createTaskInStack(stack, 0 /* userId */);
+ WindowState activityWindow = createAppWindow(task, TYPE_APPLICATION, "App Window");
+ WindowState behindWindow = createWindow(null, TYPE_SCREENSHOT, display, "Screenshot");
+
+ WindowState result = display.findScrollCaptureTargetWindow(behindWindow,
+ ActivityTaskManager.INVALID_TASK_ID);
+ assertEquals(activityWindow, result);
+ }
+
+ @Test
+ public void testFindScrollCaptureTargetWindow_taskId() {
+ DisplayContent display = createNewDisplay();
+ ActivityStack stack = createTaskStackOnDisplay(display);
+ Task task = createTaskInStack(stack, 0 /* userId */);
+ WindowState window = createAppWindow(task, TYPE_APPLICATION, "App Window");
+ WindowState behindWindow = createWindow(null, TYPE_SCREENSHOT, display, "Screenshot");
+
+ WindowState result = display.findScrollCaptureTargetWindow(null, task.mTaskId);
+ assertEquals(window, result);
+ }
+
private boolean isOptionsPanelAtRight(int displayId) {
return (mWm.getPreferredOptionsPanelGravity(displayId) & Gravity.RIGHT) == Gravity.RIGHT;
}
diff --git a/services/tests/wmtests/src/com/android/server/wm/TestIWindow.java b/services/tests/wmtests/src/com/android/server/wm/TestIWindow.java
index 91c3c27..e39b4bc 100644
--- a/services/tests/wmtests/src/com/android/server/wm/TestIWindow.java
+++ b/services/tests/wmtests/src/com/android/server/wm/TestIWindow.java
@@ -24,6 +24,7 @@
import android.util.MergedConfiguration;
import android.view.DisplayCutout;
import android.view.DragEvent;
+import android.view.IScrollCaptureController;
import android.view.IWindow;
import android.view.InsetsSourceControl;
import android.view.InsetsState;
@@ -113,6 +114,10 @@
}
@Override
+ public void requestScrollCapture(IScrollCaptureController controller) throws RemoteException {
+ }
+
+ @Override
public void showInsets(int types, boolean fromIme) throws RemoteException {
}
diff --git a/services/tests/wmtests/src/com/android/server/wm/WindowTestsBase.java b/services/tests/wmtests/src/com/android/server/wm/WindowTestsBase.java
index e561c13..6a64d1c 100644
--- a/services/tests/wmtests/src/com/android/server/wm/WindowTestsBase.java
+++ b/services/tests/wmtests/src/com/android/server/wm/WindowTestsBase.java
@@ -247,7 +247,7 @@
WindowState createAppWindow(Task task, int type, String name) {
synchronized (mWm.mGlobalLock) {
final ActivityRecord activity =
- WindowTestUtils.createTestActivityRecord(mDisplayContent);
+ WindowTestUtils.createTestActivityRecord(task.getDisplayContent());
task.addChild(activity, 0);
return createWindow(null, type, activity, name);
}