summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Mark Renouf <mrenouf@google.com> 2020-10-09 23:55:00 +0000
committer Android (Google) Code Review <android-gerrit@google.com> 2020-10-09 23:55:00 +0000
commit60eec1c72df538d188729479e399bd8989df62e5 (patch)
tree4e584aca6fd5aad01d62b33397b526a84c0c6b66
parentf339931699577ba9079f4f30006cb04c6bc92d7b (diff)
parent7d8968bc0d657b3943b7ebfc4a3466c17ed19b75 (diff)
Merge "Adds scroll capture support for RecyclerView"
-rw-r--r--core/java/com/android/internal/view/RecyclerViewCaptureHelper.java202
-rw-r--r--core/java/com/android/internal/view/ScrollCaptureInternal.java91
-rw-r--r--core/java/com/android/internal/view/ScrollCaptureViewHelper.java13
-rw-r--r--core/java/com/android/internal/view/ScrollCaptureViewSupport.java72
-rw-r--r--core/java/com/android/internal/view/ScrollViewCaptureHelper.java20
-rw-r--r--core/tests/coretests/src/com/android/internal/view/RecyclerViewCaptureHelperTest.java343
-rw-r--r--packages/SystemUI/tests/AndroidManifest.xml4
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/screenshot/RecyclerViewActivity.java80
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;
+ }
+ }
+}