diff options
| author | 2020-10-09 23:55:00 +0000 | |
|---|---|---|
| committer | 2020-10-09 23:55:00 +0000 | |
| commit | 60eec1c72df538d188729479e399bd8989df62e5 (patch) | |
| tree | 4e584aca6fd5aad01d62b33397b526a84c0c6b66 | |
| parent | f339931699577ba9079f4f30006cb04c6bc92d7b (diff) | |
| parent | 7d8968bc0d657b3943b7ebfc4a3466c17ed19b75 (diff) | |
Merge "Adds scroll capture support for RecyclerView"
8 files changed, 789 insertions, 36 deletions
diff --git a/core/java/com/android/internal/view/RecyclerViewCaptureHelper.java b/core/java/com/android/internal/view/RecyclerViewCaptureHelper.java new file mode 100644 index 000000000000..461e11668509 --- /dev/null +++ b/core/java/com/android/internal/view/RecyclerViewCaptureHelper.java @@ -0,0 +1,202 @@ +/* + * 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.animation.ValueAnimator; +import android.annotation.NonNull; +import android.graphics.Rect; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; + +/** + * ScrollCapture for RecyclerView and <i>RecyclerView-like</i> ViewGroups. + * <p> + * Requirements for proper operation: + * <ul> + * <li>at least one visible child view</li> + * <li>scrolls by pixels in response to {@link View#scrollBy(int, int)}. + * <li>reports ability to scroll with {@link View#canScrollVertically(int)} + * <li>properly implements {@link ViewParent#requestChildRectangleOnScreen(View, Rect, boolean)} + * </ul> + * + * @see ScrollCaptureViewSupport + */ +public class RecyclerViewCaptureHelper implements ScrollCaptureViewHelper<ViewGroup> { + + // Experiment + private static final boolean DISABLE_ANIMATORS = false; + // Experiment + private static final boolean STOP_RENDER_THREAD = false; + + private static final String TAG = "RVCaptureHelper"; + private int mScrollDelta; + private boolean mScrollBarWasEnabled; + private int mOverScrollMode; + private float mDurationScale; + + @Override + public void onPrepareForStart(@NonNull ViewGroup view, Rect scrollBounds) { + mScrollDelta = 0; + + mOverScrollMode = view.getOverScrollMode(); + view.setOverScrollMode(View.OVER_SCROLL_NEVER); + + mScrollBarWasEnabled = view.isVerticalScrollBarEnabled(); + view.setVerticalScrollBarEnabled(false); + if (DISABLE_ANIMATORS) { + mDurationScale = ValueAnimator.getDurationScale(); + ValueAnimator.setDurationScale(0); + } + if (STOP_RENDER_THREAD) { + view.getThreadedRenderer().stop(); + } + } + + @Override + public ScrollResult onScrollRequested(@NonNull ViewGroup recyclerView, Rect scrollBounds, + Rect requestRect) { + ScrollResult result = new ScrollResult(); + result.requestedArea = new Rect(requestRect); + result.scrollDelta = mScrollDelta; + result.availableArea = new Rect(); // empty + + Log.d(TAG, "current scrollDelta: " + mScrollDelta); + if (!recyclerView.isVisibleToUser() || recyclerView.getChildCount() == 0) { + Log.w(TAG, "recyclerView is empty or not visible, cannot continue"); + return result; // result.availableArea == empty Rect + } + + // move from scrollBounds-relative to parent-local coordinates + Rect requestedContainerBounds = new Rect(requestRect); + requestedContainerBounds.offset(0, -mScrollDelta); + requestedContainerBounds.offset(scrollBounds.left, scrollBounds.top); + + // requestedContainerBounds is now in recyclerview-local coordinates + Log.d(TAG, "requestedContainerBounds: " + requestedContainerBounds); + + // Save a copy for later + View anchor = findChildNearestTarget(recyclerView, requestedContainerBounds); + if (anchor == null) { + Log.d(TAG, "Failed to locate anchor view"); + return result; // result.availableArea == null + } + + Log.d(TAG, "Anchor view:" + anchor); + Rect requestedContentBounds = new Rect(requestedContainerBounds); + recyclerView.offsetRectIntoDescendantCoords(anchor, requestedContentBounds); + + Log.d(TAG, "requestedContentBounds = " + requestedContentBounds); + int prevAnchorTop = anchor.getTop(); + // Note: requestChildRectangleOnScreen may modify rectangle, must pass pass in a copy here + Rect input = new Rect(requestedContentBounds); + if (recyclerView.requestChildRectangleOnScreen(anchor, input, true)) { + int scrolled = prevAnchorTop - anchor.getTop(); // inverse of movement + Log.d(TAG, "RecyclerView scrolled by " + scrolled + " px"); + mScrollDelta += scrolled; // view.top-- is equivalent to parent.scrollY++ + result.scrollDelta = mScrollDelta; + Log.d(TAG, "requestedContentBounds, (post-request-rect) = " + requestedContentBounds); + } + + requestedContainerBounds.set(requestedContentBounds); + recyclerView.offsetDescendantRectToMyCoords(anchor, requestedContainerBounds); + Log.d(TAG, "requestedContainerBounds, (post-scroll): " + requestedContainerBounds); + + Rect recyclerLocalVisible = new Rect(scrollBounds); + recyclerView.getLocalVisibleRect(recyclerLocalVisible); + Log.d(TAG, "recyclerLocalVisible: " + recyclerLocalVisible); + + if (!requestedContainerBounds.intersect(recyclerLocalVisible)) { + // Requested area is still not visible + Log.d(TAG, "requested bounds not visible!"); + return result; + } + Rect available = new Rect(requestedContainerBounds); + available.offset(-scrollBounds.left, -scrollBounds.top); + available.offset(0, mScrollDelta); + result.availableArea = available; + Log.d(TAG, "availableArea: " + result.availableArea); + return result; + } + + /** + * Find a view that is located "closest" to targetRect. Returns the first view to fully + * vertically overlap the target targetRect. If none found, returns the view with an edge + * nearest the target targetRect. + * + * @param parent the parent vertical layout + * @param targetRect a rectangle in local coordinates of <code>parent</code> + * @return a child view within parent matching the criteria or null + */ + static View findChildNearestTarget(ViewGroup parent, Rect targetRect) { + View selected = null; + int minCenterDistance = Integer.MAX_VALUE; + int maxOverlap = 0; + + // allowable center-center distance, relative to targetRect. + // if within this range, taller views are preferred + final float preferredRangeFromCenterPercent = 0.25f; + final int preferredDistance = + (int) (preferredRangeFromCenterPercent * targetRect.height()); + + Rect parentLocalVis = new Rect(); + parent.getLocalVisibleRect(parentLocalVis); + Log.d(TAG, "findChildNearestTarget: parentVis=" + parentLocalVis + + " targetRect=" + targetRect); + + Rect frame = new Rect(); + for (int i = 0; i < parent.getChildCount(); i++) { + final View child = parent.getChildAt(i); + child.getHitRect(frame); + Log.d(TAG, "child #" + i + " hitRect=" + frame); + + if (child.getVisibility() != View.VISIBLE) { + Log.d(TAG, "child #" + i + " is not visible"); + continue; + } + + int centerDistance = Math.abs(targetRect.centerY() - frame.centerY()); + Log.d(TAG, "child #" + i + " : center to center: " + centerDistance + "px"); + + if (centerDistance < minCenterDistance) { + // closer to center + minCenterDistance = centerDistance; + selected = child; + } else if (frame.intersect(targetRect) && (frame.height() > preferredDistance)) { + // within X% pixels of center, but taller + selected = child; + } + } + return selected; + } + + + @Override + public void onPrepareForEnd(@NonNull ViewGroup view) { + // Restore original position and state + view.scrollBy(0, mScrollDelta); + view.setOverScrollMode(mOverScrollMode); + view.setVerticalScrollBarEnabled(mScrollBarWasEnabled); + if (DISABLE_ANIMATORS) { + ValueAnimator.setDurationScale(mDurationScale); + } + if (STOP_RENDER_THREAD) { + view.getThreadedRenderer().start(); + } + } +} diff --git a/core/java/com/android/internal/view/ScrollCaptureInternal.java b/core/java/com/android/internal/view/ScrollCaptureInternal.java index c589afdeaa1a..ae1a815910ed 100644 --- a/core/java/com/android/internal/view/ScrollCaptureInternal.java +++ b/core/java/com/android/internal/view/ScrollCaptureInternal.java @@ -17,8 +17,11 @@ package com.android.internal.view; import android.annotation.Nullable; +import android.content.Context; +import android.content.res.Resources; import android.graphics.Point; import android.graphics.Rect; +import android.util.Log; import android.view.ScrollCaptureCallback; import android.view.View; import android.view.ViewGroup; @@ -29,6 +32,12 @@ import android.view.ViewGroup; public class ScrollCaptureInternal { private static final String TAG = "ScrollCaptureInternal"; + // Log found scrolling views + private static final boolean DEBUG = true; + + // Log all investigated views, as well as heuristic checks + private static final boolean DEBUG_VERBOSE = false; + private static final int UP = -1; private static final int DOWN = 1; @@ -57,38 +66,72 @@ public class ScrollCaptureInternal { * 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) { + private static int detectScrollingType(View view) { // Must be a ViewGroup if (!(view instanceof ViewGroup)) { + if (DEBUG_VERBOSE) { + Log.v(TAG, "hint: not a subclass of ViewGroup"); + } return TYPE_FIXED; } + if (DEBUG_VERBOSE) { + Log.v(TAG, "hint: is a subclass of ViewGroup"); + } // Confirm that it can scroll. if (!(view.canScrollVertically(DOWN) || view.canScrollVertically(UP))) { // Nothing to scroll here, move along. + if (DEBUG_VERBOSE) { + Log.v(TAG, "hint: cannot be scrolled"); + } return TYPE_FIXED; } + if (DEBUG_VERBOSE) { + Log.v(TAG, "hint: can be scrolled up or down"); + } // ScrollViews accept only a single child. if (((ViewGroup) view).getChildCount() > 1) { + if (DEBUG_VERBOSE) { + Log.v(TAG, "hint: scrollable with multiple children"); + } return TYPE_RECYCLING; } + if (DEBUG_VERBOSE) { + Log.v(TAG, "hint: less than two child views"); + } //Because recycling containers don't use scrollY, a non-zero value means Scroll view. if (view.getScrollY() != 0) { + if (DEBUG_VERBOSE) { + Log.v(TAG, "hint: scrollY != 0"); + } return TYPE_SCROLLING; } + Log.v(TAG, "hint: scrollY == 0"); // Since scrollY cannot be negative, this means a Recycling view. if (view.canScrollVertically(UP)) { + if (DEBUG_VERBOSE) { + Log.v(TAG, "hint: able to scroll up"); + } return TYPE_RECYCLING; } - // canScrollVertically(UP) == false, getScrollY() == 0, getChildCount() == 1. + if (DEBUG_VERBOSE) { + Log.v(TAG, "hint: cannot be scrolled up"); + } + // 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); + if (DEBUG_VERBOSE) { + Log.v(TAG, "hint: scrollTo caused scrollY to change"); + } return TYPE_SCROLLING; } + if (DEBUG_VERBOSE) { + Log.v(TAG, "hint: scrollTo did not cause scrollY to change"); + } return TYPE_RECYCLING; } @@ -99,19 +142,61 @@ public class ScrollCaptureInternal { * @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. + if (DEBUG_VERBOSE) { + Log.v(TAG, "scroll capture: checking " + view.getClass().getName() + + "[" + resolveId(view.getContext(), view.getId()) + "]"); + } int i = detectScrollingType(view); switch (i) { case TYPE_SCROLLING: + if (DEBUG) { + Log.d(TAG, "scroll capture: FOUND " + view.getClass().getName() + + "[" + resolveId(view.getContext(), view.getId()) + "]" + + " -> TYPE_SCROLLING"); + } return new ScrollCaptureViewSupport<>((ViewGroup) view, new ScrollViewCaptureHelper()); + case TYPE_RECYCLING: + if (DEBUG) { + Log.d(TAG, "scroll capture: FOUND " + view.getClass().getName() + + "[" + resolveId(view.getContext(), view.getId()) + "]" + + " -> TYPE_RECYCLING"); + } + return new ScrollCaptureViewSupport<>((ViewGroup) view, + new RecyclerViewCaptureHelper()); + case TYPE_FIXED: + // ignore + break; + } return null; } + + // Lifted from ViewDebug (package protected) + + private static String formatIntToHexString(int value) { + return "0x" + Integer.toHexString(value).toUpperCase(); + } + + static String resolveId(Context context, int id) { + String fieldValue; + final Resources resources = context.getResources(); + if (id >= 0) { + try { + fieldValue = resources.getResourceTypeName(id) + '/' + + resources.getResourceEntryName(id); + } catch (Resources.NotFoundException e) { + fieldValue = "id/" + formatIntToHexString(id); + } + } else { + fieldValue = "NO_ID"; + } + return fieldValue; + } } diff --git a/core/java/com/android/internal/view/ScrollCaptureViewHelper.java b/core/java/com/android/internal/view/ScrollCaptureViewHelper.java index a92e978b2fc1..9829d0b7ae8d 100644 --- a/core/java/com/android/internal/view/ScrollCaptureViewHelper.java +++ b/core/java/com/android/internal/view/ScrollCaptureViewHelper.java @@ -17,7 +17,6 @@ package com.android.internal.view; import android.annotation.NonNull; -import android.annotation.Nullable; import android.graphics.Rect; import android.view.View; @@ -62,8 +61,8 @@ interface ScrollCaptureViewHelper<V extends View> { * @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() + default boolean onAcceptSession(@NonNull V view) { + return view.isVisibleToUser() && (view.canScrollVertically(UP) || view.canScrollVertically(DOWN)); } @@ -73,7 +72,7 @@ interface ScrollCaptureViewHelper<V extends View> { * * @param view the view being captured */ - default Rect onComputeScrollBounds(@Nullable V view) { + @NonNull default Rect onComputeScrollBounds(@NonNull V view) { return new Rect(view.getPaddingLeft(), view.getPaddingTop(), view.getWidth() - view.getPaddingRight(), view.getHeight() - view.getPaddingBottom()); @@ -88,7 +87,7 @@ interface ScrollCaptureViewHelper<V extends View> { * @param view the view being captured * @param scrollBounds the bounds within {@code view} where content scrolls */ - void onPrepareForStart(@NonNull V view, Rect scrollBounds); + void onPrepareForStart(@NonNull V view, @NonNull Rect scrollBounds); /** * Map the request onto the screen. @@ -105,7 +104,9 @@ interface ScrollCaptureViewHelper<V extends View> { * content to capture for the request * @return the result of the request as a {@link ScrollResult} */ - ScrollResult onScrollRequested(@NonNull V view, Rect scrollBounds, Rect requestRect); + @NonNull + ScrollResult onScrollRequested(@NonNull V view, @NonNull Rect scrollBounds, + @NonNull Rect requestRect); /** * Restore the target after capture. diff --git a/core/java/com/android/internal/view/ScrollCaptureViewSupport.java b/core/java/com/android/internal/view/ScrollCaptureViewSupport.java index 7b4f73ffde2b..85fa791b429c 100644 --- a/core/java/com/android/internal/view/ScrollCaptureViewSupport.java +++ b/core/java/com/android/internal/view/ScrollCaptureViewSupport.java @@ -23,8 +23,8 @@ 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.util.Log; import android.view.ScrollCaptureCallback; import android.view.ScrollCaptureSession; import android.view.Surface; @@ -46,8 +46,12 @@ import java.util.function.Consumer; */ public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCallback { + public static final long NO_FRAME_PRODUCED = -1; + private static final String TAG = "ScrollCaptureViewSupport"; + private static final boolean WAIT_FOR_ANIMATION = true; + private final WeakReference<V> mWeakView; private final ScrollCaptureViewHelper<V> mViewHelper; private ViewRenderer mRenderer; @@ -99,12 +103,16 @@ public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCa 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); + session.notifyBufferSent(NO_FRAME_PRODUCED, new Rect()); return; } // Ask the view to scroll as needed to bring this area into view. ScrollResult scrollResult = mViewHelper.onScrollRequested(view, session.getScrollBounds(), requestRect); + if (scrollResult.availableArea.isEmpty()) { + session.notifyBufferSent(NO_FRAME_PRODUCED, scrollResult.availableArea); + return; + } view.invalidate(); // don't wait for vsync // For image capture, shift back by scrollDelta to arrive at the location within the view @@ -112,8 +120,19 @@ public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCa Rect viewCaptureArea = new Rect(scrollResult.availableArea); viewCaptureArea.offset(0, -scrollResult.scrollDelta); - mRenderer.renderView(view, viewCaptureArea, mUiHandler, - (frameNumber) -> session.notifyBufferSent(frameNumber, scrollResult.availableArea)); + if (WAIT_FOR_ANIMATION) { + Log.d(TAG, "render: delaying until animation"); + view.postOnAnimation(() -> { + Log.d(TAG, "postOnAnimation(): rendering now"); + long resultFrame = mRenderer.renderView(view, viewCaptureArea); + Log.d(TAG, "notifyBufferSent: " + scrollResult.availableArea); + + session.notifyBufferSent(resultFrame, new Rect(scrollResult.availableArea)); + }); + } else { + long resultFrame = mRenderer.renderView(view, viewCaptureArea); + session.notifyBufferSent(resultFrame, new Rect(scrollResult.availableArea)); + } } @Override @@ -132,8 +151,7 @@ public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCa /** * 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. + * given view. */ static final class ViewRenderer { // alpha, "reasonable default" from Javadoc @@ -157,14 +175,11 @@ public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCa private final Matrix mTempMatrix = new Matrix(); private final int[] mTempLocation = new int[2]; private long mLastRenderedSourceDrawingId = -1; - - - public interface FrameCompleteListener { - void onFrameComplete(long frameNumber); - } + private Surface mSurface; ViewRenderer() { mRenderer = new HardwareRenderer(); + mRenderer.setName("ScrollCapture"); mCaptureRenderNode = new RenderNode("ScrollCaptureRoot"); mRenderer.setContentRoot(mCaptureRenderNode); @@ -173,6 +188,7 @@ public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCa } public void setSurface(Surface surface) { + mSurface = surface; mRenderer.setSurface(surface); } @@ -223,20 +239,38 @@ public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCa mCaptureRenderNode.endRecording(); } - public void renderView(View view, Rect sourceRect, Handler handler, - FrameCompleteListener frameListener) { + public long renderView(View view, Rect sourceRect) { if (updateForView(view)) { setupLighting(view); } view.invalidate(); updateRootNode(view, sourceRect); HardwareRenderer.FrameRenderRequest request = mRenderer.createRenderRequest(); - request.setVsyncTime(SystemClock.elapsedRealtimeNanos()); - // private API b/c request.setFrameCommitCallback does not provide access to frameNumber - mRenderer.setFrameCompleteCallback( - frameNr -> handler.post(() -> frameListener.onFrameComplete(frameNr))); + long timestamp = System.nanoTime(); + request.setVsyncTime(timestamp); + + // Would be nice to access nextFrameNumber from HwR without having to hold on to Surface + final long frameNumber = mSurface.getNextFrameNumber(); + + // Block until a frame is presented to the Surface request.setWaitForPresent(true); - request.syncAndDraw(); + + switch (request.syncAndDraw()) { + case HardwareRenderer.SYNC_OK: + case HardwareRenderer.SYNC_REDRAW_REQUESTED: + return frameNumber; + + case HardwareRenderer.SYNC_FRAME_DROPPED: + Log.e(TAG, "syncAndDraw(): SYNC_FRAME_DROPPED !"); + break; + case HardwareRenderer.SYNC_LOST_SURFACE_REWARD_IF_FOUND: + Log.e(TAG, "syncAndDraw(): SYNC_LOST_SURFACE !"); + break; + case HardwareRenderer.SYNC_CONTEXT_IS_STOPPED: + Log.e(TAG, "syncAndDraw(): SYNC_CONTEXT_IS_STOPPED !"); + break; + } + return NO_FRAME_PRODUCED; } public void trimMemory() { @@ -244,6 +278,7 @@ public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCa } public void destroy() { + mSurface = null; mRenderer.destroy(); } @@ -254,6 +289,5 @@ public class ScrollCaptureViewSupport<V extends View> implements ScrollCaptureCa mTempMatrix.mapRect(mTempRectF); mTempRectF.round(outRect); } - } } diff --git a/core/java/com/android/internal/view/ScrollViewCaptureHelper.java b/core/java/com/android/internal/view/ScrollViewCaptureHelper.java index 1514b9a285dd..a1d202e3a39f 100644 --- a/core/java/com/android/internal/view/ScrollViewCaptureHelper.java +++ b/core/java/com/android/internal/view/ScrollViewCaptureHelper.java @@ -57,10 +57,6 @@ public class ScrollViewCaptureHelper implements ScrollCaptureViewHelper<ViewGrou public ScrollResult 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) | | @@ -88,9 +84,6 @@ public class ScrollViewCaptureHelper implements ScrollCaptureViewHelper<ViewGrou \__ Requested Bounds[0,300 - 200,400] (200x100) */ - ScrollResult result = new ScrollResult(); - result.requestedArea = new Rect(requestRect); - // 0) adjust the requestRect to account for scroll change since start // // Scroll Bounds[50,50 - 250,250] (w=200,h=200) @@ -99,6 +92,17 @@ public class ScrollViewCaptureHelper implements ScrollCaptureViewHelper<ViewGrou // (y-100) (scrollY - mStartScrollY) int scrollDelta = view.getScrollY() - mStartScrollY; + final ScrollResult result = new ScrollResult(); + result.requestedArea = new Rect(requestRect); + result.scrollDelta = scrollDelta; + result.availableArea = new Rect(); + + final View contentView = view.getChildAt(0); // returns null, does not throw IOOBE + if (contentView == null) { + // No child view? Cannot continue. + return result; + } + // 1) Translate request rect to make it relative to container view // // Container View [0,0 - 300,300] (scrollY=200) @@ -133,7 +137,7 @@ public class ScrollViewCaptureHelper implements ScrollCaptureViewHelper<ViewGrou // TODO: crop capture area to avoid occlusions/minimize scroll changes Point offset = new Point(); - final Rect available = new Rect(requestedContentBounds); // empty + final Rect available = new Rect(requestedContentBounds); if (!view.getChildVisibleRect(contentView, available, offset)) { available.setEmpty(); result.availableArea = available; diff --git a/core/tests/coretests/src/com/android/internal/view/RecyclerViewCaptureHelperTest.java b/core/tests/coretests/src/com/android/internal/view/RecyclerViewCaptureHelperTest.java new file mode 100644 index 000000000000..88bbcc29e71a --- /dev/null +++ b/core/tests/coretests/src/com/android/internal/view/RecyclerViewCaptureHelperTest.java @@ -0,0 +1,343 @@ +/* + * 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.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE; +import static android.view.WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY; + +import static com.google.common.truth.Truth.assertThat; + +import static org.junit.Assert.fail; + +import android.app.Instrumentation; +import android.content.Context; +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.TextView; + +import androidx.test.annotation.UiThreadTest; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.android.internal.view.ScrollCaptureViewHelper.ScrollResult; +import com.android.internal.widget.LinearLayoutManager; +import com.android.internal.widget.RecyclerView; + +import com.google.common.truth.Truth; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Random; + +@RunWith(AndroidJUnit4.class) +public class RecyclerViewCaptureHelperTest { + private static final int CHILD_VIEWS = 12; + private 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 FrameLayout mParent; + private RecyclerView mTarget; + private WindowManager mWm; + + private WindowManager.LayoutParams mWindowLayoutParams; + + private Context mContext; + private float mDensity; + private LinearLayoutManager mLinearLayoutManager; + private Instrumentation mInstrumentation; + + @Before + @UiThreadTest + public void setUp() { + mInstrumentation = InstrumentationRegistry.getInstrumentation(); + mContext = mInstrumentation.getContext(); + mDensity = mContext.getResources().getDisplayMetrics().density; + + mParent = new FrameLayout(mContext); + + mTarget = new RecyclerView(mContext); + mParent.addView(mTarget, new ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)); + + mTarget.setAdapter(new TestAdapter()); + mLinearLayoutManager = + new LinearLayoutManager(mContext, LinearLayoutManager.VERTICAL, false); + mTarget.setLayoutManager(mLinearLayoutManager); + mWm = mContext.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 onScrollRequested_up_fromTop() { + mTarget.scrollBy(0, -(WINDOW_HEIGHT * 3)); + // mTarget.createSnapshot(new ViewDebug.HardwareCanvasProvider(), false); + + RecyclerViewCaptureHelper rvc = new RecyclerViewCaptureHelper(); + Rect scrollBounds = rvc.onComputeScrollBounds(mTarget); + rvc.onPrepareForStart(mTarget, scrollBounds); + + assertThat(scrollBounds.height()).isGreaterThan(CAPTURE_HEIGHT); + + Rect request = new Rect(0, -CAPTURE_HEIGHT, scrollBounds.width(), 0); + + ScrollResult scrollResult = rvc.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(scrollResult.availableArea); + } + + @Test + @UiThreadTest + public void onScrollRequested_down_fromTop() { + mTarget.scrollBy(0, -(WINDOW_HEIGHT * 3)); + + RecyclerViewCaptureHelper rvc = new RecyclerViewCaptureHelper(); + Rect scrollBounds = rvc.onComputeScrollBounds(mTarget); + rvc.onPrepareForStart(mTarget, scrollBounds); + + assertThat(scrollBounds.height()).isGreaterThan(CAPTURE_HEIGHT); + + // Capture between y = +1200 to +1800 pixels BELOW current top + Rect request = new Rect(0, WINDOW_HEIGHT, scrollBounds.width(), + WINDOW_HEIGHT + CAPTURE_HEIGHT); + + ScrollResult scrollResult = rvc.onScrollRequested(mTarget, scrollBounds, request); + assertThat(request).isEqualTo(scrollResult.requestedArea); + assertThat(request).isEqualTo(scrollResult.availableArea); + assertThat(scrollResult.scrollDelta).isEqualTo(CAPTURE_HEIGHT); + assertAvailableAreaCompletelyVisible(scrollResult, mTarget); + } + + @Test + @UiThreadTest + public void onScrollRequested_up_fromMiddle() { + mTarget.scrollBy(0, WINDOW_HEIGHT); + + RecyclerViewCaptureHelper helper = new RecyclerViewCaptureHelper(); + Rect scrollBounds = helper.onComputeScrollBounds(mTarget); + helper.onPrepareForStart(mTarget, scrollBounds); + + Rect request = new Rect(0, -CAPTURE_HEIGHT, scrollBounds.width(), 0); + + ScrollResult scrollResult = helper.onScrollRequested(mTarget, scrollBounds, request); + assertThat(request).isEqualTo(scrollResult.requestedArea); + assertThat(request).isEqualTo(scrollResult.availableArea); + assertThat(scrollResult.scrollDelta).isEqualTo(-CAPTURE_HEIGHT); + assertAvailableAreaCompletelyVisible(scrollResult, mTarget); + } + + @Test + @UiThreadTest + public void onScrollRequested_down_fromMiddle() { + mTarget.scrollBy(0, WINDOW_HEIGHT); + + RecyclerViewCaptureHelper helper = new RecyclerViewCaptureHelper(); + Rect scrollBounds = helper.onComputeScrollBounds(mTarget); + helper.onPrepareForStart(mTarget, scrollBounds); + + Rect request = new Rect(0, WINDOW_HEIGHT, scrollBounds.width(), + WINDOW_HEIGHT + CAPTURE_HEIGHT); + + ScrollResult scrollResult = helper.onScrollRequested(mTarget, scrollBounds, request); + assertThat(request).isEqualTo(scrollResult.requestedArea); + assertThat(request).isEqualTo(scrollResult.availableArea); + assertThat(scrollResult.scrollDelta).isEqualTo(CAPTURE_HEIGHT); + assertAvailableAreaCompletelyVisible(scrollResult, mTarget); + } + + @Test + @UiThreadTest + public void onScrollRequested_up_fromBottom() { + mTarget.scrollBy(0, WINDOW_HEIGHT * 2); + + RecyclerViewCaptureHelper helper = new RecyclerViewCaptureHelper(); + Rect scrollBounds = helper.onComputeScrollBounds(mTarget); + helper.onPrepareForStart(mTarget, scrollBounds); + + Rect request = new Rect(0, -CAPTURE_HEIGHT, scrollBounds.width(), 0); + + ScrollResult scrollResult = helper.onScrollRequested(mTarget, scrollBounds, request); + assertThat(request).isEqualTo(scrollResult.requestedArea); + assertThat(request).isEqualTo(scrollResult.availableArea); + assertThat(scrollResult.scrollDelta).isEqualTo(-CAPTURE_HEIGHT); + assertAvailableAreaCompletelyVisible(scrollResult, mTarget); + } + + @Test + @UiThreadTest + public void onScrollRequested_down_fromBottom() { + mTarget.scrollBy(0, WINDOW_HEIGHT * 3); + + RecyclerViewCaptureHelper rvc = new RecyclerViewCaptureHelper(); + Rect scrollBounds = rvc.onComputeScrollBounds(mTarget); + rvc.onPrepareForStart(mTarget, scrollBounds); + + Rect request = new Rect(0, WINDOW_HEIGHT, scrollBounds.width(), + WINDOW_HEIGHT + CAPTURE_HEIGHT); + + ScrollResult scrollResult = rvc.onScrollRequested(mTarget, + scrollBounds, request); + Truth.assertThat(request).isEqualTo(scrollResult.requestedArea); + + // 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(scrollResult.availableArea); + } + + @Test + @UiThreadTest + public void onScrollRequested_offTopEdge() { + mTarget.scrollBy(0, -(WINDOW_HEIGHT * 3)); + + RecyclerViewCaptureHelper helper = new RecyclerViewCaptureHelper(); + Rect scrollBounds = helper.onComputeScrollBounds(mTarget); + helper.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)); + + ScrollResult scrollResult = helper.onScrollRequested(mTarget, scrollBounds, request); + assertThat(request).isEqualTo(scrollResult.requestedArea); + + ScrollResult result = helper.onScrollRequested(mTarget, scrollBounds, request); + // The result is a partial result + Rect expectedResult = new Rect(request); + expectedResult.top += (CAPTURE_HEIGHT / 2); // top half clipped + assertThat(expectedResult).isEqualTo(result.availableArea); + assertThat(scrollResult.scrollDelta).isEqualTo(0); + assertAvailableAreaPartiallyVisible(scrollResult, mTarget); + } + + @Test + @UiThreadTest + public void onScrollRequested_offBottomEdge() { + mTarget.scrollBy(0, WINDOW_HEIGHT * 2); + + RecyclerViewCaptureHelper helper = new RecyclerViewCaptureHelper(); + Rect scrollBounds = helper.onComputeScrollBounds(mTarget); + helper.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)); + + ScrollResult result = helper.onScrollRequested(mTarget, scrollBounds, request); + + Rect expectedResult = new Rect(request); + expectedResult.bottom -= 300; // bottom half clipped + assertThat(expectedResult).isEqualTo(result.availableArea); + assertThat(result.scrollDelta).isEqualTo(0); + assertAvailableAreaPartiallyVisible(result, mTarget); + } + + static final class TestViewHolder extends RecyclerView.ViewHolder { + TestViewHolder(View itemView) { + super(itemView); + } + } + + static final class TestAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> { + private final Random mRandom = new Random(); + + @Override + public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + return new TestViewHolder(new TextView(parent.getContext())); + } + + @Override + public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) { + TextView view = (TextView) holder.itemView; + view.setText("Child #" + position); + view.setTextColor(Color.WHITE); + view.setTextSize(30f); + view.setBackgroundColor(Color.rgb(mRandom.nextFloat(), mRandom.nextFloat(), + mRandom.nextFloat())); + view.setMinHeight(CHILD_VIEW_HEIGHT); + } + + @Override + public int getItemCount() { + return CHILD_VIEWS; + } + } + + static void assertEmpty(Rect r) { + if (r != null && !r.isEmpty()) { + fail("Not true that " + r + " is empty"); + } + } + + static Rect getVisibleRect(View v) { + Rect r = new Rect(0, 0, v.getWidth(), v.getHeight()); + v.getLocalVisibleRect(r); + return r; + } + + static void assertAvailableAreaCompletelyVisible(ScrollResult result, View container) { + Rect requested = new Rect(result.availableArea); + requested.offset(0, -result.scrollDelta); // make relative + Rect localVisible = getVisibleRect(container); + if (!localVisible.contains(requested)) { + fail("Not true that all of " + requested + " is contained by " + localVisible); + } + } + + static void assertAvailableAreaPartiallyVisible(ScrollResult result, View container) { + Rect requested = new Rect(result.availableArea); + requested.offset(0, -result.scrollDelta); // make relative + Rect localVisible = getVisibleRect(container); + if (!Rect.intersects(localVisible, requested)) { + fail("Not true that any of " + requested + " is contained by " + localVisible); + } + } +} diff --git a/packages/SystemUI/tests/AndroidManifest.xml b/packages/SystemUI/tests/AndroidManifest.xml index db878458721e..2b4ed4e8f1cd 100644 --- a/packages/SystemUI/tests/AndroidManifest.xml +++ b/packages/SystemUI/tests/AndroidManifest.xml @@ -82,6 +82,10 @@ <activity android:name="com.android.systemui.screenshot.ScrollViewActivity" android:exported="false" /> + + <activity android:name="com.android.systemui.screenshot.RecyclerViewActivity" + android:exported="false" /> + <provider android:name="androidx.lifecycle.ProcessLifecycleOwnerInitializer" tools:replace="android:authorities" diff --git a/packages/SystemUI/tests/src/com/android/systemui/screenshot/RecyclerViewActivity.java b/packages/SystemUI/tests/src/com/android/systemui/screenshot/RecyclerViewActivity.java new file mode 100644 index 000000000000..fde56ba76736 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/screenshot/RecyclerViewActivity.java @@ -0,0 +1,80 @@ +/* + * 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 static android.view.ViewGroup.LayoutParams.MATCH_PARENT; + +import android.app.Activity; +import android.graphics.Color; +import android.os.Bundle; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.Nullable; + +import com.android.internal.widget.LinearLayoutManager; +import com.android.internal.widget.RecyclerView; +import com.android.internal.widget.RecyclerView.LayoutParams; + +import java.util.Random; + +public class RecyclerViewActivity extends Activity { + public static final int CHILD_VIEW_HEIGHT = 300; + private static final int CHILD_VIEWS = 12; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + RecyclerView recyclerView = new RecyclerView(this); + recyclerView.setLayoutManager(new LinearLayoutManager(this)); + recyclerView.setAdapter(new TestAdapter()); + recyclerView.setLayoutParams(new LayoutParams(MATCH_PARENT, MATCH_PARENT)); + setContentView(recyclerView); + } + + static final class TestViewHolder extends RecyclerView.ViewHolder { + TestViewHolder(View itemView) { + super(itemView); + } + } + + static final class TestAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> { + private final Random mRandom = new Random(); + + @Override + public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + return new TestViewHolder(new TextView(parent.getContext())); + } + + @Override + public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) { + TextView view = (TextView) holder.itemView; + view.setText("Child #" + position); + view.setTextColor(Color.WHITE); + view.setTextSize(30f); + view.setBackgroundColor( + Color.rgb(mRandom.nextFloat(), mRandom.nextFloat(), mRandom.nextFloat())); + view.setMinHeight(CHILD_VIEW_HEIGHT); + } + + @Override + public int getItemCount() { + return CHILD_VIEWS; + } + } +} |