diff --git a/core/java/android/view/IScrollCaptureClient.aidl b/core/java/android/view/IScrollCaptureClient.aidl new file mode 100644 index 0000000000000..5f135a37dfef0 --- /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 0000000000000..8474a00b302f6 --- /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 9f2d36de61cdd..e09bf9d2e80a9 100644 --- a/core/java/android/view/IWindow.aidl +++ b/core/java/android/view/IWindow.aidl @@ -21,15 +21,16 @@ import android.graphics.Point; 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 @@ oneway interface IWindow { * 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 b0bacb955f804..b3b53f0293823 100644 --- a/core/java/android/view/IWindowManager.aidl +++ b/core/java/android/view/IWindowManager.aidl @@ -42,6 +42,7 @@ import android.view.IDisplayFoldListener; 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 @@ interface IWindowManager * @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 0000000000000..e1a4e74436003 --- /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. + *

+ * 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. + *

+ * Terminology + *

+ *
Containing View
+ *
The view on which this callback is attached, or the root view of the window if the callback + * is assigned directly to a window.
+ * + *
Scroll Bounds
+ *
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}.
+ * + *
Scroll Delta
+ *
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. + * + *
Capture Area
+ *
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. + *
+ *
+ * + * @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. + *

+ * 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. + *

+ * 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. + *

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

+ * TODO: finalize timeout + * + * @param onReady consumer for the updated rectangle + */ + void onScrollCaptureSearch(@NonNull Consumer onReady); + + /** + * Scroll Capture has selected this callback to provide the scrolling image content. + *

+ * The onReady signal should be called when ready to begin handling image requests. + */ + void onScrollCaptureStart(@NonNull ScrollCaptureSession session, @NonNull Runnable onReady); + + /** + * An image capture has been requested from the scrolling content. + *

+ * captureArea contains the bounds of the image requested, relative to the + * rectangle provided by {@link ScrollCaptureCallback#onScrollCaptureSearch}, referred to as + * {@code scrollBounds}. + * here. + *

+ * 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. + *

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

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

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

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

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

+ * TODO: finalize timeout + *

+ * + * @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. + *

+ * 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. + *

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

+ * TODO: finalize timeout + * + * @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 0000000000000..f163124f3a984 --- /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. + *

+ * 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, " must non-null"); + requireNonNull(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 0000000000000..628e23fb3f5e0 --- /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 + * BufferQueue 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 0000000000000..f3fcabb26b315 --- /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 0000000000000..71e82c511e2cf --- /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. + *

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

+ * + *

+ * 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 mTargets; + private Handler mHandler; + private long mTimeLimitMillis; + + private Consumer mWhenComplete; + private int mPendingBoundsRequests; + private long mDeadlineMillis; + + private ScrollCaptureTarget mResult; + private boolean mFinished; + + private boolean mStarted; + + private static int area(Rect r) { + return r.width() * r.height(); + } + + private static boolean nullOrEmpty(Rect r) { + return r == null || r.isEmpty(); + } + + /** + * Binary operator which selects the best {@link ScrollCaptureTarget}. + */ + private static ScrollCaptureTarget chooseTarget(ScrollCaptureTarget a, ScrollCaptureTarget b) { + 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 targets) { + mTargets = targets; + } + + void checkThread() { + if (mHandler.getLooper() != Looper.myLooper()) { + throw new IllegalStateException("Called from wrong thread! (" + + Thread.currentThread().getName() + ")"); + } + } + + /** + * Blocks until a result is returned (after completion or timeout). + *

+ * For testing only. Normal usage should receive a callback after calling {@link #start}. + */ + @VisibleForTesting + public ScrollCaptureTarget waitForResult() throws InterruptedException { + synchronized (mLock) { + while (!mFinished) { + mLock.wait(); + } + } + return mResult; + } + + + private void supplyResult(ScrollCaptureTarget target) { + checkThread(); + if (mFinished) { + return; + } + mResult = chooseTarget(mResult, target); + boolean finish = mPendingBoundsRequests == 0 + || SystemClock.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 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 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( + // 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 implements Consumer { + final AtomicReference> mAtomicRef; + + SingletonConsumer(Consumer target) { + mAtomicRef = new AtomicReference<>(target); + } + + @Override + public void accept(T t) { + final Consumer consumer = mAtomicRef.getAndSet(null); + if (consumer != null) { + consumer.accept(t); + } + } + } +} diff --git a/core/java/android/view/View.java b/core/java/android/view/View.java index 8abe72fc91e8e..f98c1f660cfa0 100644 --- a/core/java/android/view/View.java +++ b/core/java/android/view/View.java @@ -144,6 +144,7 @@ import android.widget.ScrollBarDrawable; 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.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Queue; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Predicate; @@ -1311,7 +1313,6 @@ public class View implements Drawable.Callback, KeyEvent.Callback, */ 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 class View implements Drawable.Callback, KeyEvent.Callback, */ 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 @@ public class View implements Drawable.Callback, KeyEvent.Callback, * 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 @@ public class View implements Drawable.Callback, KeyEvent.Callback, */ 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 @@ public class View implements Drawable.Callback, KeyEvent.Callback, * 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 @@ public class View implements Drawable.Callback, KeyEvent.Callback, 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; } } @@ -29090,6 +29161,11 @@ public class View implements Drawable.Callback, KeyEvent.Callback, */ int mLeashedParentAccessibilityViewId; + /** + * + */ + ScrollCaptureInternal mScrollCaptureInternal; + /** * Creates a new set of attachment information with the specified * events handler and thread. @@ -29150,6 +29226,14 @@ public class View implements Drawable.Callback, KeyEvent.Callback, return events; } + + @Nullable + ScrollCaptureInternal getScrollCaptureInternal() { + if (mScrollCaptureInternal != null) { + mScrollCaptureInternal = new ScrollCaptureInternal(); + } + return mScrollCaptureInternal; + } } /** @@ -29683,6 +29767,104 @@ public class View implements Drawable.Callback, KeyEvent.Callback, } } + + /** + * Returns the current scroll capture hint for this view. + * + * @return the current scroll capture hint + * + * @hide + */ + @ScrollCaptureHint + public int getScrollCaptureHint() { + 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. + *

+ * 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. + *

+ * 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 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 e34e84c977ea9..7935eb1ffc396 100644 --- a/core/java/android/view/ViewGroup.java +++ b/core/java/android/view/ViewGroup.java @@ -40,6 +40,7 @@ import android.graphics.Color; 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.Collections; 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 @@ public abstract class ViewGroup extends View implements ViewParent, ViewManager 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 @@ public abstract class ViewGroup extends View implements ViewParent, ViewManager 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 @@ public abstract class ViewGroup extends View implements ViewParent, ViewManager } } - 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 @@ public abstract class ViewGroup extends View implements ViewParent, ViewManager @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 @@ public abstract class ViewGroup extends View implements ViewParent, ViewManager 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); @@ -7353,6 +7378,97 @@ public abstract class ViewGroup extends View implements ViewParent, ViewManager } } + /** + * Offsets the given rectangle in parent's local coordinates into child's coordinate space + * and clips the result to the child View's bounds, padding and clipRect if appropriate. If the + * resulting rectangle is not empty, the request is forwarded to the child. + *

+ * Note: This method does not account for any static View transformations which may be + * applied to the child view. + * + * @param child the child to dispatch to + * @param localVisibleRect the visible (clipped) area of this ViewGroup, in local coordinates + * @param windowOffset the offset of localVisibleRect within the window + * @param targets a queue to collect located targets + */ + private void dispatchTransformedScrollCaptureSearch(View child, Rect localVisibleRect, + Point windowOffset, Queue targets) { + + // copy local visible rect for modification and dispatch + final Rect childVisibleRect = getTempRect(); + childVisibleRect.set(localVisibleRect); + + // transform to child coords + final Point childWindowOffset = getTempPoint(); + childWindowOffset.set(windowOffset.x, windowOffset.y); + + final int dx = child.mLeft - mScrollX; + final int dy = child.mTop - mScrollY; + + childVisibleRect.offset(-dx, -dy); + childWindowOffset.offset(dx, dy); + + boolean rectIsVisible = true; + final int width = mRight - mLeft; + final int height = mBottom - mTop; + + // Clip to child bounds + if (getClipChildren()) { + rectIsVisible = childVisibleRect.intersect(0, 0, child.getWidth(), child.getHeight()); + } + + // Clip to child padding. + if (rectIsVisible && (child instanceof ViewGroup) + && ((ViewGroup) child).getClipToPadding()) { + rectIsVisible = childVisibleRect.intersect( + child.mPaddingLeft, child.mPaddingTop, + child.getWidth() - child.mPaddingRight, + child.getHeight() - child.mPaddingBottom); + } + // Clip to child clipBounds. + if (rectIsVisible && child.mClipBounds != null) { + rectIsVisible = childVisibleRect.intersect(child.mClipBounds); + } + if (rectIsVisible) { + child.dispatchScrollCaptureSearch(childVisibleRect, childWindowOffset, targets); + } + } + + /** + * Handle the scroll capture search request by checking this view if applicable, then to each + * child view. + * + * @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 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 ed1edc3bd5269..68a185d363156 100644 --- a/core/java/android/view/ViewRootImpl.java +++ b/core/java/android/view/ViewRootImpl.java @@ -206,6 +206,7 @@ public final class ViewRootImpl implements ViewParent, 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 @@ public final class ViewRootImpl implements ViewParent, private final InsetsController mInsetsController; private final ImeFocusController mImeFocusController; + private ScrollCaptureClient mScrollCaptureClient; + /** * @return {@link ImeFocusController} for this instance. */ @@ -661,6 +664,11 @@ public final class ViewRootImpl implements ViewParent, 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 @@ public final class ViewRootImpl implements ViewParent, // draw returns. private SurfaceControl.Transaction mRtBLASTSyncTransaction = new SurfaceControl.Transaction(); + private HashSet mRootScrollCaptureCallbacks; + private String mTag = TAG; public ViewRootImpl(Context context, Display display) { @@ -4778,6 +4788,7 @@ public final class ViewRootImpl implements ViewParent, 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 @@ public final class ViewRootImpl implements ViewParent, 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 @@ public final class ViewRootImpl implements ViewParent, 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 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. + *

+ * 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 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 @@ public final class ViewRootImpl implements ViewParent, } } + @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 ae9afabad533c..b1536484b5159 100644 --- a/core/java/android/view/Window.java +++ b/core/java/android/view/Window.java @@ -2535,6 +2535,33 @@ public abstract class Window { 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 ec51301430861..397bce44b25e4 100644 --- a/core/java/android/view/WindowlessWindowManager.java +++ b/core/java/android/view/WindowlessWindowManager.java @@ -24,7 +24,6 @@ import android.os.IBinder; 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 25c114f4b7c19..23ba6530b0727 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.util.TypedValue; 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.LayoutInflater; 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 @@ public class PhoneWindow extends Window implements MenuBuilder.Callback { : 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 47f094f292f2f..7f3eb45156545 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.os.RemoteException; 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 @@ public class BaseIWindow extends IWindow.Stub { @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 0000000000000..c589afdeaa1a2 --- /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. + *

+ * 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 0000000000000..9f100bd6440ff --- /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 { + 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. + *

+ * 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. + *

+ * 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. + *

+ * 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 0000000000000..4087eda944e05 --- /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. + *

+ * To use this class, supply the target view and an implementation of {@ScrollCaptureViewHelper} + * to the callback. + * + * @param the specific View subclass handled + * @hide + */ +public class ScrollCaptureViewSupport implements ScrollCaptureCallback { + + private final WeakReference mWeakView; + private final ScrollCaptureViewHelper mViewHelper; + private ViewRenderer mRenderer; + private Handler mUiHandler; + private boolean mStarted; + private boolean mEnded; + + static ScrollCaptureCallback createCallback(V view, + ScrollCaptureViewHelper impl) { + return new ScrollCaptureViewSupport<>(view, impl); + } + + ScrollCaptureViewSupport(V containingView, ScrollCaptureViewHelper viewHelper) { + mWeakView = new WeakReference<>(containingView); + mRenderer = new ViewRenderer(); + mUiHandler = containingView.getHandler(); + mViewHelper = viewHelper; + } + + // Base implementation of ScrollCaptureCallback + + @Override + public final void onScrollCaptureSearch(Consumer 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 0000000000000..12bd461f810b2 --- /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 ScrollView-like ViewGroups. + *

+ * Requirements for proper operation: + *

+ */ +public class ScrollViewCaptureHelper implements ScrollCaptureViewHelper { + 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 4065a6c83f8f4..a8d1605c6fddb 100644 --- a/core/res/res/values/attrs.xml +++ b/core/res/res/values/attrs.xml @@ -2527,6 +2527,21 @@ + + + + + + + + + + + + diff --git a/core/res/res/values/public.xml b/core/res/res/values/public.xml index 67d20da039253..fb887c338fc4f 100644 --- a/core/res/res/values/public.xml +++ b/core/res/res/values/public.xml @@ -3020,6 +3020,8 @@ + + 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 0000000000000..e6ac2d6c43da6 --- /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 runRunnable(int arg) { + return invocation -> { + Runnable r = invocation.getArgument(arg); + r.run(); + return null; + }; + } + + @SuppressWarnings("SameParameterValue") + private static Answer reportBufferSent(int sessionArg, long frameNum, Rect capturedArea) { + return invocation -> { + ScrollCaptureSession session = invocation.getArgument(sessionArg); + session.notifyBufferSent(frameNum, capturedArea); + return null; + }; + } + + /** @see 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 0000000000000..8b21b8ecee890 --- /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 targetQueue = new LinkedList<>(); + + // Supplies scrollBounds = null + FakeScrollCaptureCallback callback1 = new FakeScrollCaptureCallback(); + callback1.setScrollBounds(null); + ScrollCaptureTarget target1 = createTarget(callback1, new Rect(20, 30, 40, 50), + new Point(0, 0), View.SCROLL_CAPTURE_HINT_AUTO); + + // Supplies scrollBounds = empty rect + FakeScrollCaptureCallback callback2 = new FakeScrollCaptureCallback(); + callback2.setScrollBounds(new Rect()); + ScrollCaptureTarget target2 = createTarget(callback2, new Rect(20, 30, 40, 50), + new Point(0, 20), View.SCROLL_CAPTURE_HINT_INCLUDE); + + targetQueue.add(target1); + targetQueue.add(target2); + + ScrollCaptureTargetResolver resolver = new ScrollCaptureTargetResolver(targetQueue); + resolver.start(mHandler, RESOLVER_TIMEOUT_MS, mTargetConsumer); + + // Test only + resolver.waitForResult(); + + ScrollCaptureTarget result = mTargetConsumer.getLastValue(); + assertNull("Expected null due to no valid targets", result); + } + + @Test(timeout = TEST_TIMEOUT_MS) + public void testSingleTarget() throws InterruptedException { + FakeScrollCaptureCallback callback = new FakeScrollCaptureCallback(); + ScrollCaptureTarget target = createTarget(callback, + new Rect(20, 30, 40, 50), new Point(10, 10), + View.SCROLL_CAPTURE_HINT_AUTO); + callback.setScrollBounds(new Rect(2, 2, 18, 18)); + + LinkedList targetQueue = new LinkedList<>(); + targetQueue.add(target); + ScrollCaptureTargetResolver resolver = new ScrollCaptureTargetResolver(targetQueue); + resolver.start(mHandler, RESOLVER_TIMEOUT_MS, mTargetConsumer); + + // Test only + resolver.waitForResult(); + + ScrollCaptureTarget result = mTargetConsumer.getLastValue(); + assertSame("Excepted the same target as a result", target, result); + assertEquals("result has wrong scroll bounds", + new Rect(2, 2, 18, 18), result.getScrollBounds()); + } + + @Test(timeout = TEST_TIMEOUT_MS) + public void testSingleTarget_backgroundThread() throws InterruptedException { + BackgroundTestCallback callback1 = new BackgroundTestCallback(); + ScrollCaptureTarget target1 = createTarget(callback1, + new Rect(20, 30, 40, 50), new Point(10, 10), + View.SCROLL_CAPTURE_HINT_AUTO); + callback1.setDelay(100); + callback1.setScrollBounds(new Rect(2, 2, 18, 18)); + + LinkedList targetQueue = new LinkedList<>(); + targetQueue.add(target1); + + ScrollCaptureTargetResolver resolver = new ScrollCaptureTargetResolver(targetQueue); + resolver.start(mHandler, RESOLVER_TIMEOUT_MS, mTargetConsumer); + + // Test only + resolver.waitForResult(); + + ScrollCaptureTarget result = mTargetConsumer.getLastValue(); + assertSame("Excepted the single target1 as a result", target1, result); + assertEquals("Result has wrong scroll bounds", + new Rect(2, 2, 18, 18), result.getScrollBounds()); + } + + @Test(timeout = TEST_TIMEOUT_MS) + public void testPreferNonEmptyBounds() throws InterruptedException { + LinkedList targetQueue = new LinkedList<>(); + + FakeScrollCaptureCallback callback1 = new FakeScrollCaptureCallback(); + callback1.setScrollBounds(new Rect()); + ScrollCaptureTarget target1 = createTarget(callback1, new Rect(20, 30, 40, 50), + new Point(0, 0), View.SCROLL_CAPTURE_HINT_AUTO); + + FakeScrollCaptureCallback callback2 = new FakeScrollCaptureCallback(); + callback2.setScrollBounds(new Rect(0, 0, 20, 20)); + ScrollCaptureTarget target2 = createTarget(callback2, new Rect(20, 30, 40, 50), + new Point(0, 20), View.SCROLL_CAPTURE_HINT_INCLUDE); + + FakeScrollCaptureCallback callback3 = new FakeScrollCaptureCallback(); + callback3.setScrollBounds(null); + ScrollCaptureTarget target3 = createTarget(callback3, new Rect(20, 30, 40, 50), + new Point(0, 40), View.SCROLL_CAPTURE_HINT_AUTO); + + targetQueue.add(target1); + targetQueue.add(target2); // scrollBounds not null or empty() + targetQueue.add(target3); + + ScrollCaptureTargetResolver resolver = new ScrollCaptureTargetResolver(targetQueue); + resolver.start(mHandler, RESOLVER_TIMEOUT_MS, mTargetConsumer); + resolver.waitForResult(); + + ScrollCaptureTarget result = mTargetConsumer.getLastValue(); + assertEquals("Expected " + target2 + " as a result", target2, result); + assertEquals("result has wrong scroll bounds", + new Rect(0, 0, 20, 20), result.getScrollBounds()); + } + + @Test(timeout = TEST_TIMEOUT_MS) + public void testPreferHintInclude() throws InterruptedException { + LinkedList targetQueue = new LinkedList<>(); + + FakeScrollCaptureCallback callback1 = new FakeScrollCaptureCallback(); + callback1.setScrollBounds(new Rect(0, 0, 20, 20)); + ScrollCaptureTarget target1 = createTarget(callback1, new Rect(20, 30, 40, 50), + new Point(0, 0), View.SCROLL_CAPTURE_HINT_AUTO); + + FakeScrollCaptureCallback callback2 = new FakeScrollCaptureCallback(); + callback2.setScrollBounds(new Rect(1, 1, 19, 19)); + ScrollCaptureTarget target2 = createTarget(callback2, new Rect(20, 30, 40, 50), + new Point(0, 20), View.SCROLL_CAPTURE_HINT_INCLUDE); + + FakeScrollCaptureCallback callback3 = new FakeScrollCaptureCallback(); + callback3.setScrollBounds(new Rect(2, 2, 18, 18)); + ScrollCaptureTarget target3 = createTarget(callback3, new Rect(20, 30, 40, 50), + new Point(0, 40), View.SCROLL_CAPTURE_HINT_AUTO); + + targetQueue.add(target1); + targetQueue.add(target2); // * INCLUDE > AUTO + targetQueue.add(target3); + + ScrollCaptureTargetResolver resolver = new ScrollCaptureTargetResolver(targetQueue); + resolver.start(mHandler, RESOLVER_TIMEOUT_MS, mTargetConsumer); + + resolver.waitForResult(); + + ScrollCaptureTarget result = mTargetConsumer.getLastValue(); + assertEquals("input = " + targetQueue + " Expected " + target2 + + " as the result, due to hint=INCLUDE", target2, result); + assertEquals("result has wrong scroll bounds", + new Rect(1, 1, 19, 19), result.getScrollBounds()); + } + + @Test(timeout = TEST_TIMEOUT_MS) + public void testDescendantPreferred() throws InterruptedException { + LinkedList targetQueue = new LinkedList<>(); + + ViewGroup targetView1 = new FakeRootView(getTargetContext(), 0, 0, 60, 60); // 60x60 + ViewGroup targetView2 = new FakeRootView(getTargetContext(), 20, 30, 40, 50); // 20x20 + ViewGroup targetView3 = new FakeRootView(getTargetContext(), 5, 5, 15, 15); // 10x10 + + targetView1.addView(targetView2); + targetView2.addView(targetView3); + + // Create first target with an unrelated parent + FakeScrollCaptureCallback callback1 = new FakeScrollCaptureCallback(); + callback1.setScrollBounds(new Rect(0, 0, 60, 60)); + ScrollCaptureTarget target1 = createTargetWithView(targetView1, callback1, + new Rect(0, 0, 60, 60), + new Point(0, 0), View.SCROLL_CAPTURE_HINT_AUTO); + + // Create second target associated with a view within parent2 + FakeScrollCaptureCallback callback2 = new FakeScrollCaptureCallback(); + callback2.setScrollBounds(new Rect(0, 0, 20, 20)); + ScrollCaptureTarget target2 = createTargetWithView(targetView2, callback2, + new Rect(0, 0, 20, 20), + new Point(20, 30), View.SCROLL_CAPTURE_HINT_AUTO); + + // Create third target associated with a view within parent3 + FakeScrollCaptureCallback callback3 = new FakeScrollCaptureCallback(); + callback3.setScrollBounds(new Rect(0, 0, 15, 15)); + ScrollCaptureTarget target3 = createTargetWithView(targetView3, callback3, + new Rect(0, 0, 15, 15), + new Point(25, 35), View.SCROLL_CAPTURE_HINT_AUTO); + + targetQueue.add(target1); // auto, 60x60 + targetQueue.add(target2); // auto, 20x20 + targetQueue.add(target3); // auto, 15x15 <- innermost scrollable + + ScrollCaptureTargetResolver resolver = new ScrollCaptureTargetResolver(targetQueue); + resolver.start(mHandler, RESOLVER_TIMEOUT_MS, mTargetConsumer); + + // Test only + resolver.waitForResult(); + + ScrollCaptureTarget result = mTargetConsumer.getLastValue(); + assertSame("Expected target3 as the result, due to relation", target3, result); + assertEquals("result has wrong scroll bounds", + new Rect(0, 0, 15, 15), result.getScrollBounds()); + } + + /** + * If a timeout expires, late results are ignored. + */ + @Test(timeout = TEST_TIMEOUT_MS) + public void testTimeout() throws InterruptedException { + LinkedList targetQueue = new LinkedList<>(); + + // callback 1, 10x10, hint=AUTO, responds immediately from bg thread + BackgroundTestCallback callback1 = new BackgroundTestCallback(); + callback1.setScrollBounds(new Rect(5, 5, 15, 15)); + ScrollCaptureTarget target1 = createTarget( + callback1, new Rect(20, 30, 40, 50), new Point(10, 10), + View.SCROLL_CAPTURE_HINT_AUTO); + targetQueue.add(target1); + + // callback 2, 20x20, hint=AUTO, responds after 5s from bg thread + BackgroundTestCallback callback2 = new BackgroundTestCallback(); + callback2.setScrollBounds(new Rect(0, 0, 20, 20)); + callback2.setDelay(5000); + ScrollCaptureTarget target2 = createTarget( + callback2, new Rect(20, 30, 40, 50), new Point(10, 10), + View.SCROLL_CAPTURE_HINT_AUTO); + targetQueue.add(target2); + + // callback 3, 20x20, hint=INCLUDE, responds after 10s from bg thread + BackgroundTestCallback callback3 = new BackgroundTestCallback(); + callback3.setScrollBounds(new Rect(0, 0, 20, 20)); + callback3.setDelay(10000); + ScrollCaptureTarget target3 = createTarget( + callback3, new Rect(20, 30, 40, 50), new Point(10, 10), + View.SCROLL_CAPTURE_HINT_INCLUDE); + targetQueue.add(target3); + + // callback 1 will be received + // callback 2 & 3 will be ignored due to timeout + + ScrollCaptureTargetResolver resolver = new ScrollCaptureTargetResolver(targetQueue); + resolver.start(mHandler, RESOLVER_TIMEOUT_MS, mTargetConsumer); + + resolver.waitForResult(); + + ScrollCaptureTarget result = mTargetConsumer.getLastValue(); + assertSame("Expected target1 as the result, due to timeouts of others", target1, result); + assertEquals("result has wrong scroll bounds", + new Rect(5, 5, 15, 15), result.getScrollBounds()); + assertEquals("callback1 should have been called", + 1, callback1.getOnScrollCaptureSearchCount()); + assertEquals("callback2 should have been called", + 1, callback2.getOnScrollCaptureSearchCount()); + assertEquals("callback3 should have been called", + 1, callback3.getOnScrollCaptureSearchCount()); + } + + @Test(timeout = TEST_TIMEOUT_MS) + public void testWithCallbackMultipleReplies() throws InterruptedException { + // Calls response methods 3 times each + RepeatingCaptureCallback callback1 = new RepeatingCaptureCallback(3); + callback1.setScrollBounds(new Rect(2, 2, 18, 18)); + ScrollCaptureTarget target1 = createTarget(callback1, new Rect(20, 30, 40, 50), + new Point(10, 10), View.SCROLL_CAPTURE_HINT_AUTO); + + FakeScrollCaptureCallback callback2 = new FakeScrollCaptureCallback(); + callback2.setScrollBounds(new Rect(0, 0, 20, 20)); + ScrollCaptureTarget target2 = createTarget(callback2, new Rect(20, 30, 40, 50), + new Point(10, 10), View.SCROLL_CAPTURE_HINT_AUTO); + + LinkedList targetQueue = new LinkedList<>(); + targetQueue.add(target1); + targetQueue.add(target2); + + ScrollCaptureTargetResolver resolver = new ScrollCaptureTargetResolver(targetQueue); + resolver.start(mHandler, RESOLVER_TIMEOUT_MS, mTargetConsumer); + + resolver.waitForResult(); + + ScrollCaptureTarget result = mTargetConsumer.getLastValue(); + assertSame("Expected target2 as the result, due to hint=INCLUDE", target2, result); + assertEquals("result has wrong scroll bounds", + new Rect(0, 0, 20, 20), result.getScrollBounds()); + assertEquals("callback1 should have been called once", + 1, callback1.getOnScrollCaptureSearchCount()); + assertEquals("callback2 should have been called once", + 1, callback2.getOnScrollCaptureSearchCount()); + } + + private static class TargetConsumer implements Consumer { + volatile ScrollCaptureTarget mResult; + int mAcceptCount; + + ScrollCaptureTarget getLastValue() { + return mResult; + } + + int acceptCount() { + return mAcceptCount; + } + + @Override + public void accept(@Nullable ScrollCaptureTarget t) { + mAcceptCount++; + mResult = t; + } + } + + private void setupTargetView(View view, Rect localVisibleRect, int scrollCaptureHint) { + view.setScrollCaptureHint(scrollCaptureHint); + view.onVisibilityAggregated(true); + // Treat any offset as padding, outset localVisibleRect on all sides and use this as + // child bounds + Rect bounds = new Rect(localVisibleRect); + bounds.inset(-bounds.left, -bounds.top, bounds.left, bounds.top); + view.layout(bounds.left, bounds.top, bounds.right, bounds.bottom); + view.onVisibilityAggregated(true); + } + + private ScrollCaptureTarget createTarget(ScrollCaptureCallback callback, Rect localVisibleRect, + Point positionInWindow, int scrollCaptureHint) { + View mockView = new View(getTargetContext()); + return createTargetWithView(mockView, callback, localVisibleRect, positionInWindow, + scrollCaptureHint); + } + + private ScrollCaptureTarget createTargetWithView(View view, ScrollCaptureCallback callback, + Rect localVisibleRect, Point positionInWindow, int scrollCaptureHint) { + setupTargetView(view, localVisibleRect, scrollCaptureHint); + return new ScrollCaptureTarget(view, localVisibleRect, positionInWindow, callback); + } + + + static class FakeRootView extends ViewGroup implements ViewParent { + FakeRootView(Context context, int l, int t, int r, int b) { + super(context); + layout(l, t, r, b); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + } + } + + static class FakeScrollCaptureCallback implements ScrollCaptureCallback { + private Rect mScrollBounds; + private long mDelayMillis; + private int mOnScrollCaptureSearchCount; + + public int getOnScrollCaptureSearchCount() { + return mOnScrollCaptureSearchCount; + } + + @Override + public void onScrollCaptureSearch(Consumer onReady) { + mOnScrollCaptureSearchCount++; + run(() -> { + Rect b = getScrollBounds(); + onReady.accept(b); + }); + } + + @Override + public void onScrollCaptureStart(ScrollCaptureSession session, Runnable onReady) { + run(onReady); + } + + @Override + public void onScrollCaptureImageRequest(ScrollCaptureSession session, Rect captureArea) { + run(() -> session.notifyBufferSent(0, captureArea)); + } + + @Override + public void onScrollCaptureEnd(Runnable onReady) { + run(onReady); + } + + public void setScrollBounds(@Nullable Rect scrollBounds) { + mScrollBounds = scrollBounds; + } + + public void setDelay(long delayMillis) { + mDelayMillis = delayMillis; + } + + protected Rect getScrollBounds() { + return mScrollBounds; + } + + protected void run(Runnable r) { + delay(); + r.run(); + } + + protected void delay() { + if (mDelayMillis > 0) { + try { + Thread.sleep(mDelayMillis); + } catch (InterruptedException e) { + // Ignore + } + } + } + } + + static class RepeatingCaptureCallback extends FakeScrollCaptureCallback { + private int mRepeatCount; + + RepeatingCaptureCallback(int repeatCount) { + mRepeatCount = repeatCount; + } + + protected void run(Runnable r) { + delay(); + for (int i = 0; i < mRepeatCount; i++) { + r.run(); + } + } + } + + /** Response to async calls on an arbitrary background thread */ + static class BackgroundTestCallback extends FakeScrollCaptureCallback { + static int sCount = 0; + private void runOnBackgroundThread(Runnable r) { + final Runnable target = () -> { + delay(); + r.run(); + }; + Thread t = new Thread(target); + synchronized (BackgroundTestCallback.this) { + sCount++; + } + t.setName("Background-Thread-" + sCount); + t.start(); + } + + @Override + protected void run(Runnable r) { + runOnBackgroundThread(r); + } + } +} diff --git a/core/tests/coretests/src/android/view/ViewGroupScrollCaptureTest.java b/core/tests/coretests/src/android/view/ViewGroupScrollCaptureTest.java new file mode 100644 index 0000000000000..3af0533e763ca --- /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 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 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 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 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. + *

+ * 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 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 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 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 0000000000000..63a68e99b788f --- /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 18086ec0313ea..c5ac451a95391 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 0000000000000..5ced40cb1b3b6 --- /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 e5da603321cd2..899aabb2e9a74 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.util.SparseArray; 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 @@ public class SystemWindows { @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 a47cdc66fbd83..b01accab8fd90 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 @@ class DisplayContent extends WindowContainer() { + 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 f55a1b3f6ab32..a5014145aa60f 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.IOnKeyguardExitResult; 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 @@ public class WindowManagerService extends IWindowManager.Stub } } + /** + * 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 daff14992e943..80fcf2e121f4d 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_ATTACHED_ 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.anyInt; 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 @@ public class DisplayContentTests extends WindowTestsBase { 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 91c3c2782d940..e39b4bcd2eb0c 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.os.RemoteException; 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; @@ -112,6 +113,10 @@ public class TestIWindow extends IWindow.Stub { public void dispatchPointerCaptureChanged(boolean hasCapture) { } + @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 e561c13a4e994..6a64d1c976c46 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 @@ class WindowTestsBase extends SystemServiceTestsBase { 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); }