• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2020 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 android.annotation.AnyThread;
20 import android.annotation.NonNull;
21 import android.annotation.Nullable;
22 import android.annotation.UiThread;
23 import android.graphics.Rect;
24 import android.os.Handler;
25 import android.os.Looper;
26 import android.os.SystemClock;
27 import android.util.Log;
28 
29 import com.android.internal.annotations.VisibleForTesting;
30 
31 
32 import java.util.Queue;
33 import java.util.concurrent.atomic.AtomicReference;
34 import java.util.function.Consumer;
35 
36 /**
37  * Queries additional state from a list of {@link ScrollCaptureTarget targets} via asynchronous
38  * callbacks, then aggregates and reduces the target list to a single target, or null if no target
39  * is suitable.
40  * <p>
41  * The rules for selection are (in order):
42  * <ul>
43  * <li>prefer getScrollBounds(): non-empty
44  * <li>prefer View.getScrollCaptureHint == SCROLL_CAPTURE_HINT_INCLUDE
45  * <li>prefer descendants before parents
46  * <li>prefer larger area for getScrollBounds() (clipped to view bounds)
47  * </ul>
48  *
49  * <p>
50  * All calls to {@link ScrollCaptureCallback#onScrollCaptureSearch} are made on the main thread,
51  * with results are queued and consumed to the main thread as well.
52  *
53  * @see #start(Handler, long, Consumer)
54  *
55  * @hide
56  */
57 @UiThread
58 public class ScrollCaptureTargetResolver {
59     private static final String TAG = "ScrollCaptureTargetRes";
60     private static final boolean DEBUG = true;
61 
62     private final Object mLock = new Object();
63 
64     private final Queue<ScrollCaptureTarget> mTargets;
65     private Handler mHandler;
66     private long mTimeLimitMillis;
67 
68     private Consumer<ScrollCaptureTarget> mWhenComplete;
69     private int mPendingBoundsRequests;
70     private long mDeadlineMillis;
71 
72     private ScrollCaptureTarget mResult;
73     private boolean mFinished;
74 
75     private boolean mStarted;
76 
area(Rect r)77     private static int area(Rect r) {
78         return r.width() * r.height();
79     }
80 
nullOrEmpty(Rect r)81     private static boolean nullOrEmpty(Rect r) {
82         return r == null || r.isEmpty();
83     }
84 
85     /**
86      * Binary operator which selects the best {@link ScrollCaptureTarget}.
87      */
chooseTarget(ScrollCaptureTarget a, ScrollCaptureTarget b)88     private static ScrollCaptureTarget chooseTarget(ScrollCaptureTarget a, ScrollCaptureTarget b) {
89         Log.d(TAG, "chooseTarget: " + a + " or " + b);
90         // Nothing plus nothing is still nothing.
91         if (a == null && b == null) {
92             Log.d(TAG, "chooseTarget: (both null) return " + null);
93             return null;
94         }
95         // Prefer non-null.
96         if (a == null || b == null) {
97             ScrollCaptureTarget c = (a == null) ? b : a;
98             Log.d(TAG, "chooseTarget: (other is null) return " + c);
99             return c;
100 
101         }
102 
103         boolean emptyScrollBoundsA = nullOrEmpty(a.getScrollBounds());
104         boolean emptyScrollBoundsB = nullOrEmpty(b.getScrollBounds());
105         if (emptyScrollBoundsA || emptyScrollBoundsB) {
106             if (emptyScrollBoundsA && emptyScrollBoundsB) {
107                 // Both have an empty or null scrollBounds
108                 Log.d(TAG, "chooseTarget: (both have empty or null bounds) return " + null);
109                 return null;
110             }
111             // Prefer the one with a non-empty scroll bounds
112             if (emptyScrollBoundsA) {
113                 Log.d(TAG, "chooseTarget: (a has empty or null bounds) return " + b);
114                 return b;
115             }
116             Log.d(TAG, "chooseTarget: (b has empty or null bounds) return " + a);
117             return a;
118         }
119 
120         final View viewA = a.getContainingView();
121         final View viewB = b.getContainingView();
122 
123         // Prefer any view with scrollCaptureHint="INCLUDE", over one without
124         // This is an escape hatch for the next rule (descendants first)
125         boolean hintIncludeA = hasIncludeHint(viewA);
126         boolean hintIncludeB = hasIncludeHint(viewB);
127         if (hintIncludeA != hintIncludeB) {
128             ScrollCaptureTarget c = (hintIncludeA) ? a : b;
129             Log.d(TAG, "chooseTarget: (has hint=INCLUDE) return " + c);
130             return c;
131         }
132 
133         // If the views are relatives, prefer the descendant. This allows implementations to
134         // leverage nested scrolling APIs by interacting with the innermost scrollable view (as
135         // would happen with touch input).
136         if (isDescendant(viewA, viewB)) {
137             Log.d(TAG, "chooseTarget: (b is descendant of a) return " + b);
138             return b;
139         }
140         if (isDescendant(viewB, viewA)) {
141             Log.d(TAG, "chooseTarget: (a is descendant of b) return " + a);
142             return a;
143         }
144 
145         // finally, prefer one with larger scroll bounds
146         int scrollAreaA = area(a.getScrollBounds());
147         int scrollAreaB = area(b.getScrollBounds());
148         ScrollCaptureTarget c = (scrollAreaA >= scrollAreaB) ? a : b;
149         Log.d(TAG, "chooseTarget: return " + c);
150         return c;
151     }
152 
153     /**
154      * Creates an instance to query and filter {@code target}.
155      *
156      * @param targets   a list of {@link ScrollCaptureTarget} as collected by {@link
157      *                  View#dispatchScrollCaptureSearch}.
158      * @param uiHandler the UI thread handler for the view tree
159      * @see #start(long, Consumer)
160      */
ScrollCaptureTargetResolver(Queue<ScrollCaptureTarget> targets)161     public ScrollCaptureTargetResolver(Queue<ScrollCaptureTarget> targets) {
162         mTargets = targets;
163     }
164 
checkThread()165     void checkThread() {
166         if (mHandler.getLooper() != Looper.myLooper()) {
167             throw new IllegalStateException("Called from wrong thread! ("
168                     + Thread.currentThread().getName() + ")");
169         }
170     }
171 
172     /**
173      * Blocks until a result is returned (after completion or timeout).
174      * <p>
175      * For testing only. Normal usage should receive a callback after calling {@link #start}.
176      */
177     @VisibleForTesting
waitForResult()178     public ScrollCaptureTarget waitForResult() throws InterruptedException {
179         synchronized (mLock) {
180             while (!mFinished) {
181                 mLock.wait();
182             }
183         }
184         return mResult;
185     }
186 
187 
supplyResult(ScrollCaptureTarget target)188     private void supplyResult(ScrollCaptureTarget target) {
189         checkThread();
190         if (mFinished) {
191             return;
192         }
193         mResult = chooseTarget(mResult, target);
194         boolean finish = mPendingBoundsRequests == 0
195                 || SystemClock.elapsedRealtime() >= mDeadlineMillis;
196         if (finish) {
197             System.err.println("We think we're done, or timed out");
198             mPendingBoundsRequests = 0;
199             mWhenComplete.accept(mResult);
200             synchronized (mLock) {
201                 mFinished = true;
202                 mLock.notify();
203             }
204             mWhenComplete = null;
205         }
206     }
207 
208     /**
209      * Asks all targets for {@link ScrollCaptureCallback#onScrollCaptureSearch(Consumer)
210      * scrollBounds}, and selects the primary target according to the {@link
211      * #chooseTarget} function.
212      *
213      * @param timeLimitMillis the amount of time to wait for all responses before delivering the top
214      *                        result
215      * @param resultConsumer  the consumer to receive the primary target
216      */
217     @AnyThread
start(Handler uiHandler, long timeLimitMillis, Consumer<ScrollCaptureTarget> resultConsumer)218     public void start(Handler uiHandler, long timeLimitMillis,
219             Consumer<ScrollCaptureTarget> resultConsumer) {
220         synchronized (mLock) {
221             if (mStarted) {
222                 throw new IllegalStateException("already started!");
223             }
224             if (timeLimitMillis < 0) {
225                 throw new IllegalArgumentException("Time limit must be positive");
226             }
227             mHandler = uiHandler;
228             mTimeLimitMillis = timeLimitMillis;
229             mWhenComplete = resultConsumer;
230             if (mTargets.isEmpty()) {
231                 mHandler.post(() -> supplyResult(null));
232                 return;
233             }
234             mStarted = true;
235             uiHandler.post(() -> run(timeLimitMillis, resultConsumer));
236         }
237     }
238 
239 
run(long timeLimitMillis, Consumer<ScrollCaptureTarget> resultConsumer)240     private void run(long timeLimitMillis, Consumer<ScrollCaptureTarget> resultConsumer) {
241         checkThread();
242 
243         mPendingBoundsRequests = mTargets.size();
244         for (ScrollCaptureTarget target : mTargets) {
245             queryTarget(target);
246         }
247         mDeadlineMillis = SystemClock.elapsedRealtime() + mTimeLimitMillis;
248         mHandler.postAtTime(mTimeoutRunnable, mDeadlineMillis);
249     }
250 
251     private final Runnable mTimeoutRunnable = new Runnable() {
252         @Override
253         public void run() {
254             checkThread();
255             supplyResult(null);
256         }
257     };
258 
259 
260     /**
261      * Adds a target to the list and requests {@link ScrollCaptureCallback#onScrollCaptureSearch}
262      * scrollBounds} from it. Results are returned by a call to {@link #onScrollBoundsProvided}.
263      *
264      * @param target the target to add
265      */
266     @UiThread
queryTarget(@onNull ScrollCaptureTarget target)267     private void queryTarget(@NonNull ScrollCaptureTarget target) {
268         checkThread();
269         final ScrollCaptureCallback callback = target.getCallback();
270         // from the UI thread, request scroll bounds
271         callback.onScrollCaptureSearch(
272                 // allow only one callback to onReady.accept():
273                 new SingletonConsumer<Rect>(
274                         // Queue and consume on the UI thread
275                         ((scrollBounds) -> mHandler.post(
276                                 () -> onScrollBoundsProvided(target, scrollBounds)))));
277 
278     }
279 
280     @UiThread
onScrollBoundsProvided(ScrollCaptureTarget target, @Nullable Rect scrollBounds)281     private void onScrollBoundsProvided(ScrollCaptureTarget target, @Nullable Rect scrollBounds) {
282         checkThread();
283         if (mFinished) {
284             return;
285         }
286 
287         // Record progress.
288         mPendingBoundsRequests--;
289 
290         // Remove the timeout.
291         mHandler.removeCallbacks(mTimeoutRunnable);
292 
293         boolean doneOrTimedOut = mPendingBoundsRequests == 0
294                 || SystemClock.elapsedRealtime() >= mDeadlineMillis;
295 
296         final View containingView = target.getContainingView();
297         if (!nullOrEmpty(scrollBounds) && containingView.isAggregatedVisible()) {
298             target.updatePositionInWindow();
299             target.setScrollBounds(scrollBounds);
300             supplyResult(target);
301         }
302 
303         System.err.println("mPendingBoundsRequests: " + mPendingBoundsRequests);
304         System.err.println("mDeadlineMillis: " + mDeadlineMillis);
305         System.err.println("SystemClock.elapsedRealtime(): " + SystemClock.elapsedRealtime());
306 
307         if (!mFinished) {
308             // Reschedule the timeout.
309             System.err.println(
310                     "We think we're NOT done yet and will check back at " + mDeadlineMillis);
311             mHandler.postAtTime(mTimeoutRunnable, mDeadlineMillis);
312         }
313     }
314 
hasIncludeHint(View view)315     private static boolean hasIncludeHint(View view) {
316         return (view.getScrollCaptureHint() & View.SCROLL_CAPTURE_HINT_INCLUDE) != 0;
317     }
318 
319     /**
320      * Determines if {@code otherView} is a descendant of {@code view}.
321      *
322      * @param view      a view
323      * @param otherView another view
324      * @return true if {@code view} is an ancestor of {@code otherView}
325      */
isDescendant(@onNull View view, @NonNull View otherView)326     private static boolean isDescendant(@NonNull View view, @NonNull View otherView) {
327         if (view == otherView) {
328             return false;
329         }
330         ViewParent otherParent = otherView.getParent();
331         while (otherParent != view && otherParent != null) {
332             otherParent = otherParent.getParent();
333         }
334         return otherParent == view;
335     }
336 
findRelation(@onNull View a, @NonNull View b)337     private static int findRelation(@NonNull View a, @NonNull View b) {
338         if (a == b) {
339             return 0;
340         }
341 
342         ViewParent parentA = a.getParent();
343         ViewParent parentB = b.getParent();
344 
345         while (parentA != null || parentB != null) {
346             if (parentA == parentB) {
347                 return 0;
348             }
349             if (parentA == b) {
350                 return 1; // A is descendant of B
351             }
352             if (parentB == a) {
353                 return -1; // B is descendant of A
354             }
355             if (parentA != null) {
356                 parentA = parentA.getParent();
357             }
358             if (parentB != null) {
359                 parentB = parentB.getParent();
360             }
361         }
362         return 0;
363     }
364 
365     /**
366      * A safe wrapper for a consumer callbacks intended to accept a single value. It ensures
367      * that the receiver of the consumer does not retain a reference to {@code target} after use nor
368      * cause race conditions by invoking {@link Consumer#accept accept} more than once.
369      *
370      * @param target the target consumer
371      */
372     static class SingletonConsumer<T> implements Consumer<T> {
373         final AtomicReference<Consumer<T>> mAtomicRef;
374 
SingletonConsumer(Consumer<T> target)375         SingletonConsumer(Consumer<T> target) {
376             mAtomicRef = new AtomicReference<>(target);
377         }
378 
379         @Override
accept(T t)380         public void accept(T t) {
381             final Consumer<T> consumer = mAtomicRef.getAndSet(null);
382             if (consumer != null) {
383                 consumer.accept(t);
384             }
385         }
386     }
387 }
388