• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2021 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.view;
18 
19 import static android.view.flags.Flags.scrollCaptureTargetZOrderFix;
20 
21 import static java.util.Comparator.comparing;
22 import static java.util.Objects.requireNonNull;
23 import static java.util.Objects.requireNonNullElse;
24 
25 import android.annotation.NonNull;
26 import android.annotation.Nullable;
27 import android.annotation.UiThread;
28 import android.graphics.Point;
29 import android.graphics.Rect;
30 import android.os.CancellationSignal;
31 import android.util.IndentingPrintWriter;
32 
33 import com.android.internal.annotations.VisibleForTesting;
34 
35 import java.util.ArrayList;
36 import java.util.Comparator;
37 import java.util.List;
38 import java.util.concurrent.Executor;
39 import java.util.function.Consumer;
40 
41 /**
42  * Collects nodes in the view hierarchy which have been identified as scrollable content.
43  *
44  * @hide
45  */
46 @UiThread
47 public final class ScrollCaptureSearchResults {
48     private final Executor mExecutor;
49     private final List<ScrollCaptureTarget> mTargets;
50     private final CancellationSignal mCancel;
51 
52     private Runnable mOnCompleteListener;
53     private int mCompleted;
54     private boolean mComplete = true;
55 
ScrollCaptureSearchResults(Executor executor)56     public ScrollCaptureSearchResults(Executor executor) {
57         mExecutor = executor;
58         mTargets = new ArrayList<>();
59         mCancel = new CancellationSignal();
60     }
61 
62     // Public
63 
64     /**
65      * Add the given target to the results.
66      *
67      * @param target the target to consider
68      */
addTarget(@onNull ScrollCaptureTarget target)69     public void addTarget(@NonNull ScrollCaptureTarget target) {
70         requireNonNull(target);
71 
72         mTargets.add(target);
73         mComplete = false;
74         final ScrollCaptureCallback callback = target.getCallback();
75         final Consumer<Rect> consumer = new SearchRequest(target);
76 
77         // Defer so the view hierarchy scan completes first
78         mExecutor.execute(
79                 () -> callback.onScrollCaptureSearch(mCancel, consumer));
80     }
81 
isComplete()82     public boolean isComplete() {
83         return mComplete;
84     }
85 
86     /**
87      * Provides a callback to be invoked as soon as all responses have been received from all
88      * targets to this point.
89      *
90      * @param onComplete listener to add
91      */
setOnCompleteListener(Runnable onComplete)92     public void setOnCompleteListener(Runnable onComplete) {
93         if (mComplete) {
94             onComplete.run();
95         } else {
96             mOnCompleteListener = onComplete;
97         }
98     }
99 
100     /**
101      * Indicates whether the search results are empty.
102      *
103      * @return true if no targets have been added
104      */
isEmpty()105     public boolean isEmpty() {
106         return mTargets.isEmpty();
107     }
108 
109     /**
110      * Force the results to complete now, cancelling any pending requests and calling a complete
111      * listener if provided.
112      */
finish()113     public void finish() {
114         if (!mComplete) {
115             mCancel.cancel();
116             signalComplete();
117         }
118     }
119 
signalComplete()120     private void signalComplete() {
121         mComplete = true;
122         if (!scrollCaptureTargetZOrderFix()) {
123             mTargets.sort(PRIORITY_ORDER);
124         }
125         if (mOnCompleteListener != null) {
126             mOnCompleteListener.run();
127             mOnCompleteListener = null;
128         }
129     }
130 
131     @VisibleForTesting
getTargets()132     public List<ScrollCaptureTarget> getTargets() {
133         return new ArrayList<>(mTargets);
134     }
135 
getScrollBoundsInWindow(@ullable ScrollCaptureTarget target)136     private Rect getScrollBoundsInWindow(@Nullable ScrollCaptureTarget target) {
137         if (target == null || target.getScrollBounds() == null) {
138             return new Rect();
139         }
140         Rect windowRect = new Rect(target.getScrollBounds());
141         Point windowPosition = target.getPositionInWindow();
142         windowRect.offset(windowPosition.x, windowPosition.y);
143         return windowRect;
144     }
145 
146     /**
147      * Get the top ranked result out of all completed requests.
148      *
149      * @return the top ranked result
150      */
151     @Nullable
getTopResult()152     public ScrollCaptureTarget getTopResult() {
153         if (!scrollCaptureTargetZOrderFix()) {
154             ScrollCaptureTarget target = mTargets.isEmpty() ? null : mTargets.get(0);
155             return target != null && target.getScrollBounds() != null ? target : null;
156         }
157         List<ScrollCaptureTarget> filtered = new ArrayList<>();
158 
159         mTargets.removeIf(a -> nullOrEmpty(a.getScrollBounds()));
160 
161         // Remove scroll targets obscured or covered by other scrolling views.
162         nextTarget:
163         for (int i = 0; i <  mTargets.size(); i++) {
164             ScrollCaptureTarget current = mTargets.get(i);
165 
166             View currentView = current.getContainingView();
167 
168             // Nested scroll containers:
169             // Check if the next view is a child of the current. If so, skip the current.
170             if (i + 1 < mTargets.size()) {
171                 ScrollCaptureTarget next = mTargets.get(i + 1);
172                 View nextView = next.getContainingView();
173                 // Honor explicit include hint on parent as escape hatch (unless both have it)
174                 if (isDescendant(currentView, nextView)
175                         && (!hasIncludeHint(currentView) || hasIncludeHint(nextView))) {
176                     continue;
177                 }
178             }
179 
180             // Check if any views will be drawn partially or fully over this one.
181             for (int j = i + 1; j < mTargets.size(); j++) {
182                 ScrollCaptureTarget above = mTargets.get(j);
183                 if (Rect.intersects(getScrollBoundsInWindow(current),
184                         getScrollBoundsInWindow(above))) {
185                     continue nextTarget;
186                 }
187             }
188 
189             filtered.add(current);
190         }
191 
192         // natural order, false->true
193         Comparator<ScrollCaptureTarget> byIncludeHintPresence = comparing(
194                 ScrollCaptureSearchResults::hasIncludeHint);
195 
196         // natural order, smallest->largest area
197         Comparator<ScrollCaptureTarget> byArea = comparing(
198                 target -> area(requireNonNullElse(target.getScrollBounds(), new Rect())));
199 
200         // The top result is the last one (with include hint if present, then by largest area)
201         filtered.sort(byIncludeHintPresence.thenComparing(byArea));
202         return filtered.isEmpty() ? null : filtered.getLast();
203     }
204 
205     private class SearchRequest implements Consumer<Rect> {
206         private ScrollCaptureTarget mTarget;
207 
SearchRequest(ScrollCaptureTarget target)208         SearchRequest(ScrollCaptureTarget target) {
209             mTarget = target;
210         }
211 
212         @Override
accept(Rect scrollBounds)213         public void accept(Rect scrollBounds) {
214             if (mTarget == null || mCancel.isCanceled()) {
215                 return;
216             }
217             mExecutor.execute(() -> consume(scrollBounds));
218         }
219 
consume(Rect scrollBounds)220         private void consume(Rect scrollBounds) {
221             if (mTarget == null || mCancel.isCanceled()) {
222                 return;
223             }
224             if (!nullOrEmpty(scrollBounds)) {
225                 mTarget.setScrollBounds(scrollBounds);
226                 mTarget.updatePositionInWindow();
227             }
228             mCompleted++;
229             mTarget = null;
230 
231             // All done?
232             if (mCompleted == mTargets.size()) {
233                 signalComplete();
234             }
235         }
236     }
237 
238     private static final int AFTER = 1;
239     private static final int BEFORE = -1;
240     private static final int EQUAL = 0;
241 
242     static final Comparator<ScrollCaptureTarget> PRIORITY_ORDER = (a, b) -> {
243         if (a == null && b == null) {
244             return 0;
245         } else if (a == null || b == null) {
246             return (a == null) ? 1 : -1;
247         }
248 
249         boolean emptyScrollBoundsA = nullOrEmpty(a.getScrollBounds());
250         boolean emptyScrollBoundsB = nullOrEmpty(b.getScrollBounds());
251         if (emptyScrollBoundsA || emptyScrollBoundsB) {
252             if (emptyScrollBoundsA && emptyScrollBoundsB) {
253                 return EQUAL;
254             }
255             // Prefer the one with a non-empty scroll bounds
256             if (emptyScrollBoundsA) {
257                 return AFTER;
258             }
259             return BEFORE;
260         }
261 
262         final View viewA = a.getContainingView();
263         final View viewB = b.getContainingView();
264 
265         // Prefer any view with scrollCaptureHint="INCLUDE", over one without
266         // This is an escape hatch for the next rule (descendants first)
267         boolean hintIncludeA = hasIncludeHint(viewA);
268         boolean hintIncludeB = hasIncludeHint(viewB);
269         if (hintIncludeA != hintIncludeB) {
270             return (hintIncludeA) ? BEFORE : AFTER;
271         }
272         // If the views are relatives, prefer the descendant. This allows implementations to
273         // leverage nested scrolling APIs by interacting with the innermost scrollable view (as
274         // would happen with touch input).
275         if (isDescendant(viewA, viewB)) {
276             return BEFORE;
277         }
278         if (isDescendant(viewB, viewA)) {
279             return AFTER;
280         }
281 
282         // finally, prefer one with larger scroll bounds
283         int scrollAreaA = area(a.getScrollBounds());
284         int scrollAreaB = area(b.getScrollBounds());
285         return (scrollAreaA >= scrollAreaB) ? BEFORE : AFTER;
286     };
287 
area(Rect r)288     private static int area(Rect r) {
289         return r.width() * r.height();
290     }
291 
nullOrEmpty(Rect r)292     private static boolean nullOrEmpty(Rect r) {
293         return r == null || r.isEmpty();
294     }
295 
hasIncludeHint(ScrollCaptureTarget target)296     private static boolean hasIncludeHint(ScrollCaptureTarget target) {
297         return hasIncludeHint(target.getContainingView());
298     }
299 
hasIncludeHint(View view)300     private static boolean hasIncludeHint(View view) {
301         return (view.getScrollCaptureHint() & View.SCROLL_CAPTURE_HINT_INCLUDE) != 0;
302     }
303 
304     /**
305      * Determines if {@code otherView} is a descendant of {@code view}.
306      *
307      * @param view      a view
308      * @param otherView another view
309      * @return true if {@code view} is an ancestor of {@code otherView}
310      */
isDescendant(@onNull View view, @NonNull View otherView)311     private static boolean isDescendant(@NonNull View view, @NonNull View otherView) {
312         if (view == otherView) {
313             return false;
314         }
315         ViewParent otherParent = otherView.getParent();
316         while (otherParent != view && otherParent != null) {
317             otherParent = otherParent.getParent();
318         }
319         return otherParent == view;
320     }
321 
dump(IndentingPrintWriter writer)322     void dump(IndentingPrintWriter writer) {
323         writer.println("results:");
324         writer.increaseIndent();
325         writer.println("complete: " + isComplete());
326         writer.println("cancelled: " + mCancel.isCanceled());
327         writer.println("targets:");
328         writer.increaseIndent();
329         if (isEmpty()) {
330             writer.println("None");
331         } else {
332             for (int i = 0; i < mTargets.size(); i++) {
333                 writer.println("[" + i + "]");
334                 writer.increaseIndent();
335                 mTargets.get(i).dump(writer);
336                 writer.decreaseIndent();
337             }
338             writer.decreaseIndent();
339         }
340         writer.decreaseIndent();
341     }
342 }
343