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