|  | /* | 
|  | * 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 android.view; | 
|  |  | 
|  | import android.annotation.AnyThread; | 
|  | import android.annotation.NonNull; | 
|  | import android.annotation.Nullable; | 
|  | import android.annotation.UiThread; | 
|  | import android.graphics.Rect; | 
|  | import android.os.Handler; | 
|  | import android.os.Looper; | 
|  | import android.os.SystemClock; | 
|  | import android.util.Log; | 
|  |  | 
|  | import com.android.internal.annotations.VisibleForTesting; | 
|  |  | 
|  |  | 
|  | import java.util.Queue; | 
|  | import java.util.concurrent.atomic.AtomicReference; | 
|  | import java.util.function.Consumer; | 
|  |  | 
|  | /** | 
|  | * Queries additional state from a list of {@link ScrollCaptureTarget targets} via asynchronous | 
|  | * callbacks, then aggregates and reduces the target list to a single target, or null if no target | 
|  | * is suitable. | 
|  | * <p> | 
|  | * The rules for selection are (in order): | 
|  | * <ul> | 
|  | * <li>prefer getScrollBounds(): non-empty | 
|  | * <li>prefer View.getScrollCaptureHint == SCROLL_CAPTURE_HINT_INCLUDE | 
|  | * <li>prefer descendants before parents | 
|  | * <li>prefer larger area for getScrollBounds() (clipped to view bounds) | 
|  | * </ul> | 
|  | * | 
|  | * <p> | 
|  | * All calls to {@link ScrollCaptureCallback#onScrollCaptureSearch} are made on the main thread, | 
|  | * with results are queued and consumed to the main thread as well. | 
|  | * | 
|  | * @see #start(Handler, long, Consumer) | 
|  | * | 
|  | * @hide | 
|  | */ | 
|  | @UiThread | 
|  | public class ScrollCaptureTargetResolver { | 
|  | private static final String TAG = "ScrollCaptureTargetRes"; | 
|  | private static final boolean DEBUG = true; | 
|  |  | 
|  | private final Object mLock = new Object(); | 
|  |  | 
|  | private final Queue<ScrollCaptureTarget> mTargets; | 
|  | private Handler mHandler; | 
|  | private long mTimeLimitMillis; | 
|  |  | 
|  | private Consumer<ScrollCaptureTarget> mWhenComplete; | 
|  | private int mPendingBoundsRequests; | 
|  | private long mDeadlineMillis; | 
|  |  | 
|  | private ScrollCaptureTarget mResult; | 
|  | private boolean mFinished; | 
|  |  | 
|  | private boolean mStarted; | 
|  |  | 
|  | private static int area(Rect r) { | 
|  | return r.width() * r.height(); | 
|  | } | 
|  |  | 
|  | private static boolean nullOrEmpty(Rect r) { | 
|  | return r == null || r.isEmpty(); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Binary operator which selects the best {@link ScrollCaptureTarget}. | 
|  | */ | 
|  | private static ScrollCaptureTarget chooseTarget(ScrollCaptureTarget a, ScrollCaptureTarget b) { | 
|  | Log.d(TAG, "chooseTarget: " + a + " or " + b); | 
|  | // Nothing plus nothing is still nothing. | 
|  | if (a == null && b == null) { | 
|  | Log.d(TAG, "chooseTarget: (both null) return " + null); | 
|  | return null; | 
|  | } | 
|  | // Prefer non-null. | 
|  | if (a == null || b == null) { | 
|  | ScrollCaptureTarget c = (a == null) ? b : a; | 
|  | Log.d(TAG, "chooseTarget: (other is null) return " + c); | 
|  | return c; | 
|  |  | 
|  | } | 
|  |  | 
|  | boolean emptyScrollBoundsA = nullOrEmpty(a.getScrollBounds()); | 
|  | boolean emptyScrollBoundsB = nullOrEmpty(b.getScrollBounds()); | 
|  | if (emptyScrollBoundsA || emptyScrollBoundsB) { | 
|  | if (emptyScrollBoundsA && emptyScrollBoundsB) { | 
|  | // Both have an empty or null scrollBounds | 
|  | Log.d(TAG, "chooseTarget: (both have empty or null bounds) return " + null); | 
|  | return null; | 
|  | } | 
|  | // Prefer the one with a non-empty scroll bounds | 
|  | if (emptyScrollBoundsA) { | 
|  | Log.d(TAG, "chooseTarget: (a has empty or null bounds) return " + b); | 
|  | return b; | 
|  | } | 
|  | Log.d(TAG, "chooseTarget: (b has empty or null bounds) return " + a); | 
|  | return a; | 
|  | } | 
|  |  | 
|  | final View viewA = a.getContainingView(); | 
|  | final View viewB = b.getContainingView(); | 
|  |  | 
|  | // Prefer any view with scrollCaptureHint="INCLUDE", over one without | 
|  | // This is an escape hatch for the next rule (descendants first) | 
|  | boolean hintIncludeA = hasIncludeHint(viewA); | 
|  | boolean hintIncludeB = hasIncludeHint(viewB); | 
|  | if (hintIncludeA != hintIncludeB) { | 
|  | ScrollCaptureTarget c = (hintIncludeA) ? a : b; | 
|  | Log.d(TAG, "chooseTarget: (has hint=INCLUDE) return " + c); | 
|  | return c; | 
|  | } | 
|  |  | 
|  | // If the views are relatives, prefer the descendant. This allows implementations to | 
|  | // leverage nested scrolling APIs by interacting with the innermost scrollable view (as | 
|  | // would happen with touch input). | 
|  | if (isDescendant(viewA, viewB)) { | 
|  | Log.d(TAG, "chooseTarget: (b is descendant of a) return " + b); | 
|  | return b; | 
|  | } | 
|  | if (isDescendant(viewB, viewA)) { | 
|  | Log.d(TAG, "chooseTarget: (a is descendant of b) return " + a); | 
|  | return a; | 
|  | } | 
|  |  | 
|  | // finally, prefer one with larger scroll bounds | 
|  | int scrollAreaA = area(a.getScrollBounds()); | 
|  | int scrollAreaB = area(b.getScrollBounds()); | 
|  | ScrollCaptureTarget c = (scrollAreaA >= scrollAreaB) ? a : b; | 
|  | Log.d(TAG, "chooseTarget: return " + c); | 
|  | return c; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Creates an instance to query and filter {@code target}. | 
|  | * | 
|  | * @param targets   a list of {@link ScrollCaptureTarget} as collected by {@link | 
|  | *                  View#dispatchScrollCaptureSearch}. | 
|  | * @param uiHandler the UI thread handler for the view tree | 
|  | * @see #start(long, Consumer) | 
|  | */ | 
|  | public ScrollCaptureTargetResolver(Queue<ScrollCaptureTarget> targets) { | 
|  | mTargets = targets; | 
|  | } | 
|  |  | 
|  | void checkThread() { | 
|  | if (mHandler.getLooper() != Looper.myLooper()) { | 
|  | throw new IllegalStateException("Called from wrong thread! (" | 
|  | + Thread.currentThread().getName() + ")"); | 
|  | } | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Blocks until a result is returned (after completion or timeout). | 
|  | * <p> | 
|  | * For testing only. Normal usage should receive a callback after calling {@link #start}. | 
|  | */ | 
|  | @VisibleForTesting | 
|  | public ScrollCaptureTarget waitForResult() throws InterruptedException { | 
|  | synchronized (mLock) { | 
|  | while (!mFinished) { | 
|  | mLock.wait(); | 
|  | } | 
|  | } | 
|  | return mResult; | 
|  | } | 
|  |  | 
|  |  | 
|  | private void supplyResult(ScrollCaptureTarget target) { | 
|  | checkThread(); | 
|  | if (mFinished) { | 
|  | return; | 
|  | } | 
|  | mResult = chooseTarget(mResult, target); | 
|  | boolean finish = mPendingBoundsRequests == 0 | 
|  | || SystemClock.elapsedRealtime() >= mDeadlineMillis; | 
|  | if (finish) { | 
|  | System.err.println("We think we're done, or timed out"); | 
|  | mPendingBoundsRequests = 0; | 
|  | mWhenComplete.accept(mResult); | 
|  | synchronized (mLock) { | 
|  | mFinished = true; | 
|  | mLock.notify(); | 
|  | } | 
|  | mWhenComplete = null; | 
|  | } | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Asks all targets for {@link ScrollCaptureCallback#onScrollCaptureSearch(Consumer) | 
|  | * scrollBounds}, and selects the primary target according to the {@link | 
|  | * #chooseTarget} function. | 
|  | * | 
|  | * @param timeLimitMillis the amount of time to wait for all responses before delivering the top | 
|  | *                        result | 
|  | * @param resultConsumer  the consumer to receive the primary target | 
|  | */ | 
|  | @AnyThread | 
|  | public void start(Handler uiHandler, long timeLimitMillis, | 
|  | Consumer<ScrollCaptureTarget> resultConsumer) { | 
|  | synchronized (mLock) { | 
|  | if (mStarted) { | 
|  | throw new IllegalStateException("already started!"); | 
|  | } | 
|  | if (timeLimitMillis < 0) { | 
|  | throw new IllegalArgumentException("Time limit must be positive"); | 
|  | } | 
|  | mHandler = uiHandler; | 
|  | mTimeLimitMillis = timeLimitMillis; | 
|  | mWhenComplete = resultConsumer; | 
|  | if (mTargets.isEmpty()) { | 
|  | mHandler.post(() -> supplyResult(null)); | 
|  | return; | 
|  | } | 
|  | mStarted = true; | 
|  | uiHandler.post(() -> run(timeLimitMillis, resultConsumer)); | 
|  | } | 
|  | } | 
|  |  | 
|  |  | 
|  | private void run(long timeLimitMillis, Consumer<ScrollCaptureTarget> resultConsumer) { | 
|  | checkThread(); | 
|  |  | 
|  | mPendingBoundsRequests = mTargets.size(); | 
|  | for (ScrollCaptureTarget target : mTargets) { | 
|  | queryTarget(target); | 
|  | } | 
|  | mDeadlineMillis = SystemClock.elapsedRealtime() + mTimeLimitMillis; | 
|  | mHandler.postAtTime(mTimeoutRunnable, mDeadlineMillis); | 
|  | } | 
|  |  | 
|  | private final Runnable mTimeoutRunnable = new Runnable() { | 
|  | @Override | 
|  | public void run() { | 
|  | checkThread(); | 
|  | supplyResult(null); | 
|  | } | 
|  | }; | 
|  |  | 
|  |  | 
|  | /** | 
|  | * Adds a target to the list and requests {@link ScrollCaptureCallback#onScrollCaptureSearch} | 
|  | * scrollBounds} from it. Results are returned by a call to {@link #onScrollBoundsProvided}. | 
|  | * | 
|  | * @param target the target to add | 
|  | */ | 
|  | @UiThread | 
|  | private void queryTarget(@NonNull ScrollCaptureTarget target) { | 
|  | checkThread(); | 
|  | final ScrollCaptureCallback callback = target.getCallback(); | 
|  | // from the UI thread, request scroll bounds | 
|  | callback.onScrollCaptureSearch( | 
|  | // allow only one callback to onReady.accept(): | 
|  | new SingletonConsumer<Rect>( | 
|  | // Queue and consume on the UI thread | 
|  | ((scrollBounds) -> mHandler.post( | 
|  | () -> onScrollBoundsProvided(target, scrollBounds))))); | 
|  |  | 
|  | } | 
|  |  | 
|  | @UiThread | 
|  | private void onScrollBoundsProvided(ScrollCaptureTarget target, @Nullable Rect scrollBounds) { | 
|  | checkThread(); | 
|  | if (mFinished) { | 
|  | return; | 
|  | } | 
|  |  | 
|  | // Record progress. | 
|  | mPendingBoundsRequests--; | 
|  |  | 
|  | // Remove the timeout. | 
|  | mHandler.removeCallbacks(mTimeoutRunnable); | 
|  |  | 
|  | boolean doneOrTimedOut = mPendingBoundsRequests == 0 | 
|  | || SystemClock.elapsedRealtime() >= mDeadlineMillis; | 
|  |  | 
|  | final View containingView = target.getContainingView(); | 
|  | if (!nullOrEmpty(scrollBounds) && containingView.isAggregatedVisible()) { | 
|  | target.updatePositionInWindow(); | 
|  | target.setScrollBounds(scrollBounds); | 
|  | supplyResult(target); | 
|  | } | 
|  |  | 
|  | System.err.println("mPendingBoundsRequests: " + mPendingBoundsRequests); | 
|  | System.err.println("mDeadlineMillis: " + mDeadlineMillis); | 
|  | System.err.println("SystemClock.elapsedRealtime(): " + SystemClock.elapsedRealtime()); | 
|  |  | 
|  | if (!mFinished) { | 
|  | // Reschedule the timeout. | 
|  | System.err.println( | 
|  | "We think we're NOT done yet and will check back at " + mDeadlineMillis); | 
|  | mHandler.postAtTime(mTimeoutRunnable, mDeadlineMillis); | 
|  | } | 
|  | } | 
|  |  | 
|  | private static boolean hasIncludeHint(View view) { | 
|  | return (view.getScrollCaptureHint() & View.SCROLL_CAPTURE_HINT_INCLUDE) != 0; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Determines if {@code otherView} is a descendant of {@code view}. | 
|  | * | 
|  | * @param view      a view | 
|  | * @param otherView another view | 
|  | * @return true if {@code view} is an ancestor of {@code otherView} | 
|  | */ | 
|  | private static boolean isDescendant(@NonNull View view, @NonNull View otherView) { | 
|  | if (view == otherView) { | 
|  | return false; | 
|  | } | 
|  | ViewParent otherParent = otherView.getParent(); | 
|  | while (otherParent != view && otherParent != null) { | 
|  | otherParent = otherParent.getParent(); | 
|  | } | 
|  | return otherParent == view; | 
|  | } | 
|  |  | 
|  | private static int findRelation(@NonNull View a, @NonNull View b) { | 
|  | if (a == b) { | 
|  | return 0; | 
|  | } | 
|  |  | 
|  | ViewParent parentA = a.getParent(); | 
|  | ViewParent parentB = b.getParent(); | 
|  |  | 
|  | while (parentA != null || parentB != null) { | 
|  | if (parentA == parentB) { | 
|  | return 0; | 
|  | } | 
|  | if (parentA == b) { | 
|  | return 1; // A is descendant of B | 
|  | } | 
|  | if (parentB == a) { | 
|  | return -1; // B is descendant of A | 
|  | } | 
|  | if (parentA != null) { | 
|  | parentA = parentA.getParent(); | 
|  | } | 
|  | if (parentB != null) { | 
|  | parentB = parentB.getParent(); | 
|  | } | 
|  | } | 
|  | return 0; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * A safe wrapper for a consumer callbacks intended to accept a single value. It ensures | 
|  | * that the receiver of the consumer does not retain a reference to {@code target} after use nor | 
|  | * cause race conditions by invoking {@link Consumer#accept accept} more than once. | 
|  | * | 
|  | * @param target the target consumer | 
|  | */ | 
|  | static class SingletonConsumer<T> implements Consumer<T> { | 
|  | final AtomicReference<Consumer<T>> mAtomicRef; | 
|  |  | 
|  | SingletonConsumer(Consumer<T> target) { | 
|  | mAtomicRef = new AtomicReference<>(target); | 
|  | } | 
|  |  | 
|  | @Override | 
|  | public void accept(T t) { | 
|  | final Consumer<T> consumer = mAtomicRef.getAndSet(null); | 
|  | if (consumer != null) { | 
|  | consumer.accept(t); | 
|  | } | 
|  | } | 
|  | } | 
|  | } |