/* * 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. *

* The rules for selection are (in order): *

* *

* 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 mTargets; private Handler mHandler; private long mTimeLimitMillis; private Consumer 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 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). *

* 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 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 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( // 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 implements Consumer { final AtomicReference> mAtomicRef; SingletonConsumer(Consumer target) { mAtomicRef = new AtomicReference<>(target); } @Override public void accept(T t) { final Consumer consumer = mAtomicRef.getAndSet(null); if (consumer != null) { consumer.accept(t); } } } }