/* * Copyright (C) 2021 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 static java.util.Objects.requireNonNull; import android.annotation.NonNull; import android.annotation.UiThread; import android.graphics.Rect; import android.os.CancellationSignal; import android.util.IndentingPrintWriter; import com.android.internal.annotations.VisibleForTesting; import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.concurrent.Executor; import java.util.function.Consumer; /** * Collects nodes in the view hierarchy which have been identified as scrollable content. * * @hide */ @UiThread public final class ScrollCaptureSearchResults { private final Executor mExecutor; private final List mTargets; private final CancellationSignal mCancel; private Runnable mOnCompleteListener; private int mCompleted; private boolean mComplete = true; public ScrollCaptureSearchResults(Executor executor) { mExecutor = executor; mTargets = new ArrayList<>(); mCancel = new CancellationSignal(); } // Public /** * Add the given target to the results. * * @param target the target to consider */ public void addTarget(@NonNull ScrollCaptureTarget target) { requireNonNull(target); mTargets.add(target); mComplete = false; final ScrollCaptureCallback callback = target.getCallback(); final Consumer consumer = new SearchRequest(target); // Defer so the view hierarchy scan completes first mExecutor.execute( () -> callback.onScrollCaptureSearch(mCancel, consumer)); } public boolean isComplete() { return mComplete; } /** * Provides a callback to be invoked as soon as all responses have been received from all * targets to this point. * * @param onComplete listener to add */ public void setOnCompleteListener(Runnable onComplete) { if (mComplete) { onComplete.run(); } else { mOnCompleteListener = onComplete; } } /** * Indicates whether the search results are empty. * * @return true if no targets have been added */ public boolean isEmpty() { return mTargets.isEmpty(); } /** * Force the results to complete now, cancelling any pending requests and calling a complete * listener if provided. */ public void finish() { if (!mComplete) { mCancel.cancel(); signalComplete(); } } private void signalComplete() { mComplete = true; mTargets.sort(PRIORITY_ORDER); if (mOnCompleteListener != null) { mOnCompleteListener.run(); mOnCompleteListener = null; } } @VisibleForTesting public List getTargets() { return new ArrayList<>(mTargets); } /** * Get the top ranked result out of all completed requests. * * @return the top ranked result */ public ScrollCaptureTarget getTopResult() { ScrollCaptureTarget target = mTargets.isEmpty() ? null : mTargets.get(0); return target != null && target.getScrollBounds() != null ? target : null; } private class SearchRequest implements Consumer { private ScrollCaptureTarget mTarget; SearchRequest(ScrollCaptureTarget target) { mTarget = target; } @Override public void accept(Rect scrollBounds) { if (mTarget == null || mCancel.isCanceled()) { return; } mExecutor.execute(() -> consume(scrollBounds)); } private void consume(Rect scrollBounds) { if (mTarget == null || mCancel.isCanceled()) { return; } if (!nullOrEmpty(scrollBounds)) { mTarget.setScrollBounds(scrollBounds); mTarget.updatePositionInWindow(); } mCompleted++; mTarget = null; // All done? if (mCompleted == mTargets.size()) { signalComplete(); } } } private static final int AFTER = 1; private static final int BEFORE = -1; private static final int EQUAL = 0; static final Comparator PRIORITY_ORDER = (a, b) -> { if (a == null && b == null) { return 0; } else if (a == null || b == null) { return (a == null) ? 1 : -1; } boolean emptyScrollBoundsA = nullOrEmpty(a.getScrollBounds()); boolean emptyScrollBoundsB = nullOrEmpty(b.getScrollBounds()); if (emptyScrollBoundsA || emptyScrollBoundsB) { if (emptyScrollBoundsA && emptyScrollBoundsB) { return EQUAL; } // Prefer the one with a non-empty scroll bounds if (emptyScrollBoundsA) { return AFTER; } return BEFORE; } 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) { return (hintIncludeA) ? BEFORE : AFTER; } // 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)) { return BEFORE; } if (isDescendant(viewB, viewA)) { return AFTER; } // finally, prefer one with larger scroll bounds int scrollAreaA = area(a.getScrollBounds()); int scrollAreaB = area(b.getScrollBounds()); return (scrollAreaA >= scrollAreaB) ? BEFORE : AFTER; }; private static int area(Rect r) { return r.width() * r.height(); } private static boolean nullOrEmpty(Rect r) { return r == null || r.isEmpty(); } 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; } void dump(IndentingPrintWriter writer) { writer.println("results:"); writer.increaseIndent(); writer.println("complete: " + isComplete()); writer.println("cancelled: " + mCancel.isCanceled()); writer.println("targets:"); writer.increaseIndent(); if (isEmpty()) { writer.println("None"); } else { for (int i = 0; i < mTargets.size(); i++) { writer.println("[" + i + "]"); writer.increaseIndent(); mTargets.get(i).dump(writer); writer.decreaseIndent(); } writer.decreaseIndent(); } writer.decreaseIndent(); } }