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 java.util.Objects.requireNonNull;
20 
21 import android.annotation.NonNull;
22 import android.annotation.UiThread;
23 import android.graphics.Rect;
24 import android.os.CancellationSignal;
25 import android.util.IndentingPrintWriter;
26 
27 import com.android.internal.annotations.VisibleForTesting;
28 
29 import java.util.ArrayList;
30 import java.util.Comparator;
31 import java.util.List;
32 import java.util.concurrent.Executor;
33 import java.util.function.Consumer;
34 
35 /**
36  * Collects nodes in the view hierarchy which have been identified as scrollable content.
37  *
38  * @hide
39  */
40 @UiThread
41 public final class ScrollCaptureSearchResults {
42     private final Executor mExecutor;
43     private final List<ScrollCaptureTarget> mTargets;
44     private final CancellationSignal mCancel;
45 
46     private Runnable mOnCompleteListener;
47     private int mCompleted;
48     private boolean mComplete = true;
49 
ScrollCaptureSearchResults(Executor executor)50     public ScrollCaptureSearchResults(Executor executor) {
51         mExecutor = executor;
52         mTargets = new ArrayList<>();
53         mCancel = new CancellationSignal();
54     }
55 
56     // Public
57 
58     /**
59      * Add the given target to the results.
60      *
61      * @param target the target to consider
62      */
addTarget(@onNull ScrollCaptureTarget target)63     public void addTarget(@NonNull ScrollCaptureTarget target) {
64         requireNonNull(target);
65 
66         mTargets.add(target);
67         mComplete = false;
68         final ScrollCaptureCallback callback = target.getCallback();
69         final Consumer<Rect> consumer = new SearchRequest(target);
70 
71         // Defer so the view hierarchy scan completes first
72         mExecutor.execute(
73                 () -> callback.onScrollCaptureSearch(mCancel, consumer));
74     }
75 
isComplete()76     public boolean isComplete() {
77         return mComplete;
78     }
79 
80     /**
81      * Provides a callback to be invoked as soon as all responses have been received from all
82      * targets to this point.
83      *
84      * @param onComplete listener to add
85      */
setOnCompleteListener(Runnable onComplete)86     public void setOnCompleteListener(Runnable onComplete) {
87         if (mComplete) {
88             onComplete.run();
89         } else {
90             mOnCompleteListener = onComplete;
91         }
92     }
93 
94     /**
95      * Indicates whether the search results are empty.
96      *
97      * @return true if no targets have been added
98      */
isEmpty()99     public boolean isEmpty() {
100         return mTargets.isEmpty();
101     }
102 
103     /**
104      * Force the results to complete now, cancelling any pending requests and calling a complete
105      * listener if provided.
106      */
finish()107     public void finish() {
108         if (!mComplete) {
109             mCancel.cancel();
110             signalComplete();
111         }
112     }
113 
signalComplete()114     private void signalComplete() {
115         mComplete = true;
116         mTargets.sort(PRIORITY_ORDER);
117         if (mOnCompleteListener != null) {
118             mOnCompleteListener.run();
119             mOnCompleteListener = null;
120         }
121     }
122 
123     @VisibleForTesting
getTargets()124     public List<ScrollCaptureTarget> getTargets() {
125         return new ArrayList<>(mTargets);
126     }
127 
128     /**
129      * Get the top ranked result out of all completed requests.
130      *
131      * @return the top ranked result
132      */
getTopResult()133     public ScrollCaptureTarget getTopResult() {
134         ScrollCaptureTarget target = mTargets.isEmpty() ? null : mTargets.get(0);
135         return target != null && target.getScrollBounds() != null ? target : null;
136     }
137 
138     private class SearchRequest implements Consumer<Rect> {
139         private ScrollCaptureTarget mTarget;
140 
SearchRequest(ScrollCaptureTarget target)141         SearchRequest(ScrollCaptureTarget target) {
142             mTarget = target;
143         }
144 
145         @Override
accept(Rect scrollBounds)146         public void accept(Rect scrollBounds) {
147             if (mTarget == null || mCancel.isCanceled()) {
148                 return;
149             }
150             mExecutor.execute(() -> consume(scrollBounds));
151         }
152 
consume(Rect scrollBounds)153         private void consume(Rect scrollBounds) {
154             if (mTarget == null || mCancel.isCanceled()) {
155                 return;
156             }
157             if (!nullOrEmpty(scrollBounds)) {
158                 mTarget.setScrollBounds(scrollBounds);
159                 mTarget.updatePositionInWindow();
160             }
161             mCompleted++;
162             mTarget = null;
163 
164             // All done?
165             if (mCompleted == mTargets.size()) {
166                 signalComplete();
167             }
168         }
169     }
170 
171     private static final int AFTER = 1;
172     private static final int BEFORE = -1;
173     private static final int EQUAL = 0;
174 
175     static final Comparator<ScrollCaptureTarget> PRIORITY_ORDER = (a, b) -> {
176         if (a == null && b == null) {
177             return 0;
178         } else if (a == null || b == null) {
179             return (a == null) ? 1 : -1;
180         }
181 
182         boolean emptyScrollBoundsA = nullOrEmpty(a.getScrollBounds());
183         boolean emptyScrollBoundsB = nullOrEmpty(b.getScrollBounds());
184         if (emptyScrollBoundsA || emptyScrollBoundsB) {
185             if (emptyScrollBoundsA && emptyScrollBoundsB) {
186                 return EQUAL;
187             }
188             // Prefer the one with a non-empty scroll bounds
189             if (emptyScrollBoundsA) {
190                 return AFTER;
191             }
192             return BEFORE;
193         }
194 
195         final View viewA = a.getContainingView();
196         final View viewB = b.getContainingView();
197 
198         // Prefer any view with scrollCaptureHint="INCLUDE", over one without
199         // This is an escape hatch for the next rule (descendants first)
200         boolean hintIncludeA = hasIncludeHint(viewA);
201         boolean hintIncludeB = hasIncludeHint(viewB);
202         if (hintIncludeA != hintIncludeB) {
203             return (hintIncludeA) ? BEFORE : AFTER;
204         }
205         // If the views are relatives, prefer the descendant. This allows implementations to
206         // leverage nested scrolling APIs by interacting with the innermost scrollable view (as
207         // would happen with touch input).
208         if (isDescendant(viewA, viewB)) {
209             return BEFORE;
210         }
211         if (isDescendant(viewB, viewA)) {
212             return AFTER;
213         }
214 
215         // finally, prefer one with larger scroll bounds
216         int scrollAreaA = area(a.getScrollBounds());
217         int scrollAreaB = area(b.getScrollBounds());
218         return (scrollAreaA >= scrollAreaB) ? BEFORE : AFTER;
219     };
220 
area(Rect r)221     private static int area(Rect r) {
222         return r.width() * r.height();
223     }
224 
nullOrEmpty(Rect r)225     private static boolean nullOrEmpty(Rect r) {
226         return r == null || r.isEmpty();
227     }
228 
hasIncludeHint(View view)229     private static boolean hasIncludeHint(View view) {
230         return (view.getScrollCaptureHint() & View.SCROLL_CAPTURE_HINT_INCLUDE) != 0;
231     }
232 
233     /**
234      * Determines if {@code otherView} is a descendant of {@code view}.
235      *
236      * @param view      a view
237      * @param otherView another view
238      * @return true if {@code view} is an ancestor of {@code otherView}
239      */
isDescendant(@onNull View view, @NonNull View otherView)240     private static boolean isDescendant(@NonNull View view, @NonNull View otherView) {
241         if (view == otherView) {
242             return false;
243         }
244         ViewParent otherParent = otherView.getParent();
245         while (otherParent != view && otherParent != null) {
246             otherParent = otherParent.getParent();
247         }
248         return otherParent == view;
249     }
250 
dump(IndentingPrintWriter writer)251     void dump(IndentingPrintWriter writer) {
252         writer.println("results:");
253         writer.increaseIndent();
254         writer.println("complete: " + isComplete());
255         writer.println("cancelled: " + mCancel.isCanceled());
256         writer.println("targets:");
257         writer.increaseIndent();
258         if (isEmpty()) {
259             writer.println("None");
260         } else {
261             for (int i = 0; i < mTargets.size(); i++) {
262                 writer.println("[" + i + "]");
263                 writer.increaseIndent();
264                 mTargets.get(i).dump(writer);
265                 writer.decreaseIndent();
266             }
267             writer.decreaseIndent();
268         }
269         writer.decreaseIndent();
270     }
271 }
272