1 /* 2 * Copyright (C) 2021 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 static android.view.flags.Flags.scrollCaptureTargetZOrderFix; 20 21 import static java.util.Comparator.comparing; 22 import static java.util.Objects.requireNonNull; 23 import static java.util.Objects.requireNonNullElse; 24 25 import android.annotation.NonNull; 26 import android.annotation.Nullable; 27 import android.annotation.UiThread; 28 import android.graphics.Point; 29 import android.graphics.Rect; 30 import android.os.CancellationSignal; 31 import android.util.IndentingPrintWriter; 32 33 import com.android.internal.annotations.VisibleForTesting; 34 35 import java.util.ArrayList; 36 import java.util.Comparator; 37 import java.util.List; 38 import java.util.concurrent.Executor; 39 import java.util.function.Consumer; 40 41 /** 42 * Collects nodes in the view hierarchy which have been identified as scrollable content. 43 * 44 * @hide 45 */ 46 @UiThread 47 public final class ScrollCaptureSearchResults { 48 private final Executor mExecutor; 49 private final List<ScrollCaptureTarget> mTargets; 50 private final CancellationSignal mCancel; 51 52 private Runnable mOnCompleteListener; 53 private int mCompleted; 54 private boolean mComplete = true; 55 ScrollCaptureSearchResults(Executor executor)56 public ScrollCaptureSearchResults(Executor executor) { 57 mExecutor = executor; 58 mTargets = new ArrayList<>(); 59 mCancel = new CancellationSignal(); 60 } 61 62 // Public 63 64 /** 65 * Add the given target to the results. 66 * 67 * @param target the target to consider 68 */ addTarget(@onNull ScrollCaptureTarget target)69 public void addTarget(@NonNull ScrollCaptureTarget target) { 70 requireNonNull(target); 71 72 mTargets.add(target); 73 mComplete = false; 74 final ScrollCaptureCallback callback = target.getCallback(); 75 final Consumer<Rect> consumer = new SearchRequest(target); 76 77 // Defer so the view hierarchy scan completes first 78 mExecutor.execute( 79 () -> callback.onScrollCaptureSearch(mCancel, consumer)); 80 } 81 isComplete()82 public boolean isComplete() { 83 return mComplete; 84 } 85 86 /** 87 * Provides a callback to be invoked as soon as all responses have been received from all 88 * targets to this point. 89 * 90 * @param onComplete listener to add 91 */ setOnCompleteListener(Runnable onComplete)92 public void setOnCompleteListener(Runnable onComplete) { 93 if (mComplete) { 94 onComplete.run(); 95 } else { 96 mOnCompleteListener = onComplete; 97 } 98 } 99 100 /** 101 * Indicates whether the search results are empty. 102 * 103 * @return true if no targets have been added 104 */ isEmpty()105 public boolean isEmpty() { 106 return mTargets.isEmpty(); 107 } 108 109 /** 110 * Force the results to complete now, cancelling any pending requests and calling a complete 111 * listener if provided. 112 */ finish()113 public void finish() { 114 if (!mComplete) { 115 mCancel.cancel(); 116 signalComplete(); 117 } 118 } 119 signalComplete()120 private void signalComplete() { 121 mComplete = true; 122 if (!scrollCaptureTargetZOrderFix()) { 123 mTargets.sort(PRIORITY_ORDER); 124 } 125 if (mOnCompleteListener != null) { 126 mOnCompleteListener.run(); 127 mOnCompleteListener = null; 128 } 129 } 130 131 @VisibleForTesting getTargets()132 public List<ScrollCaptureTarget> getTargets() { 133 return new ArrayList<>(mTargets); 134 } 135 getScrollBoundsInWindow(@ullable ScrollCaptureTarget target)136 private Rect getScrollBoundsInWindow(@Nullable ScrollCaptureTarget target) { 137 if (target == null || target.getScrollBounds() == null) { 138 return new Rect(); 139 } 140 Rect windowRect = new Rect(target.getScrollBounds()); 141 Point windowPosition = target.getPositionInWindow(); 142 windowRect.offset(windowPosition.x, windowPosition.y); 143 return windowRect; 144 } 145 146 /** 147 * Get the top ranked result out of all completed requests. 148 * 149 * @return the top ranked result 150 */ 151 @Nullable getTopResult()152 public ScrollCaptureTarget getTopResult() { 153 if (!scrollCaptureTargetZOrderFix()) { 154 ScrollCaptureTarget target = mTargets.isEmpty() ? null : mTargets.get(0); 155 return target != null && target.getScrollBounds() != null ? target : null; 156 } 157 List<ScrollCaptureTarget> filtered = new ArrayList<>(); 158 159 mTargets.removeIf(a -> nullOrEmpty(a.getScrollBounds())); 160 161 // Remove scroll targets obscured or covered by other scrolling views. 162 nextTarget: 163 for (int i = 0; i < mTargets.size(); i++) { 164 ScrollCaptureTarget current = mTargets.get(i); 165 166 View currentView = current.getContainingView(); 167 168 // Nested scroll containers: 169 // Check if the next view is a child of the current. If so, skip the current. 170 if (i + 1 < mTargets.size()) { 171 ScrollCaptureTarget next = mTargets.get(i + 1); 172 View nextView = next.getContainingView(); 173 // Honor explicit include hint on parent as escape hatch (unless both have it) 174 if (isDescendant(currentView, nextView) 175 && (!hasIncludeHint(currentView) || hasIncludeHint(nextView))) { 176 continue; 177 } 178 } 179 180 // Check if any views will be drawn partially or fully over this one. 181 for (int j = i + 1; j < mTargets.size(); j++) { 182 ScrollCaptureTarget above = mTargets.get(j); 183 if (Rect.intersects(getScrollBoundsInWindow(current), 184 getScrollBoundsInWindow(above))) { 185 continue nextTarget; 186 } 187 } 188 189 filtered.add(current); 190 } 191 192 // natural order, false->true 193 Comparator<ScrollCaptureTarget> byIncludeHintPresence = comparing( 194 ScrollCaptureSearchResults::hasIncludeHint); 195 196 // natural order, smallest->largest area 197 Comparator<ScrollCaptureTarget> byArea = comparing( 198 target -> area(requireNonNullElse(target.getScrollBounds(), new Rect()))); 199 200 // The top result is the last one (with include hint if present, then by largest area) 201 filtered.sort(byIncludeHintPresence.thenComparing(byArea)); 202 return filtered.isEmpty() ? null : filtered.getLast(); 203 } 204 205 private class SearchRequest implements Consumer<Rect> { 206 private ScrollCaptureTarget mTarget; 207 SearchRequest(ScrollCaptureTarget target)208 SearchRequest(ScrollCaptureTarget target) { 209 mTarget = target; 210 } 211 212 @Override accept(Rect scrollBounds)213 public void accept(Rect scrollBounds) { 214 if (mTarget == null || mCancel.isCanceled()) { 215 return; 216 } 217 mExecutor.execute(() -> consume(scrollBounds)); 218 } 219 consume(Rect scrollBounds)220 private void consume(Rect scrollBounds) { 221 if (mTarget == null || mCancel.isCanceled()) { 222 return; 223 } 224 if (!nullOrEmpty(scrollBounds)) { 225 mTarget.setScrollBounds(scrollBounds); 226 mTarget.updatePositionInWindow(); 227 } 228 mCompleted++; 229 mTarget = null; 230 231 // All done? 232 if (mCompleted == mTargets.size()) { 233 signalComplete(); 234 } 235 } 236 } 237 238 private static final int AFTER = 1; 239 private static final int BEFORE = -1; 240 private static final int EQUAL = 0; 241 242 static final Comparator<ScrollCaptureTarget> PRIORITY_ORDER = (a, b) -> { 243 if (a == null && b == null) { 244 return 0; 245 } else if (a == null || b == null) { 246 return (a == null) ? 1 : -1; 247 } 248 249 boolean emptyScrollBoundsA = nullOrEmpty(a.getScrollBounds()); 250 boolean emptyScrollBoundsB = nullOrEmpty(b.getScrollBounds()); 251 if (emptyScrollBoundsA || emptyScrollBoundsB) { 252 if (emptyScrollBoundsA && emptyScrollBoundsB) { 253 return EQUAL; 254 } 255 // Prefer the one with a non-empty scroll bounds 256 if (emptyScrollBoundsA) { 257 return AFTER; 258 } 259 return BEFORE; 260 } 261 262 final View viewA = a.getContainingView(); 263 final View viewB = b.getContainingView(); 264 265 // Prefer any view with scrollCaptureHint="INCLUDE", over one without 266 // This is an escape hatch for the next rule (descendants first) 267 boolean hintIncludeA = hasIncludeHint(viewA); 268 boolean hintIncludeB = hasIncludeHint(viewB); 269 if (hintIncludeA != hintIncludeB) { 270 return (hintIncludeA) ? BEFORE : AFTER; 271 } 272 // If the views are relatives, prefer the descendant. This allows implementations to 273 // leverage nested scrolling APIs by interacting with the innermost scrollable view (as 274 // would happen with touch input). 275 if (isDescendant(viewA, viewB)) { 276 return BEFORE; 277 } 278 if (isDescendant(viewB, viewA)) { 279 return AFTER; 280 } 281 282 // finally, prefer one with larger scroll bounds 283 int scrollAreaA = area(a.getScrollBounds()); 284 int scrollAreaB = area(b.getScrollBounds()); 285 return (scrollAreaA >= scrollAreaB) ? BEFORE : AFTER; 286 }; 287 area(Rect r)288 private static int area(Rect r) { 289 return r.width() * r.height(); 290 } 291 nullOrEmpty(Rect r)292 private static boolean nullOrEmpty(Rect r) { 293 return r == null || r.isEmpty(); 294 } 295 hasIncludeHint(ScrollCaptureTarget target)296 private static boolean hasIncludeHint(ScrollCaptureTarget target) { 297 return hasIncludeHint(target.getContainingView()); 298 } 299 hasIncludeHint(View view)300 private static boolean hasIncludeHint(View view) { 301 return (view.getScrollCaptureHint() & View.SCROLL_CAPTURE_HINT_INCLUDE) != 0; 302 } 303 304 /** 305 * Determines if {@code otherView} is a descendant of {@code view}. 306 * 307 * @param view a view 308 * @param otherView another view 309 * @return true if {@code view} is an ancestor of {@code otherView} 310 */ isDescendant(@onNull View view, @NonNull View otherView)311 private static boolean isDescendant(@NonNull View view, @NonNull View otherView) { 312 if (view == otherView) { 313 return false; 314 } 315 ViewParent otherParent = otherView.getParent(); 316 while (otherParent != view && otherParent != null) { 317 otherParent = otherParent.getParent(); 318 } 319 return otherParent == view; 320 } 321 dump(IndentingPrintWriter writer)322 void dump(IndentingPrintWriter writer) { 323 writer.println("results:"); 324 writer.increaseIndent(); 325 writer.println("complete: " + isComplete()); 326 writer.println("cancelled: " + mCancel.isCanceled()); 327 writer.println("targets:"); 328 writer.increaseIndent(); 329 if (isEmpty()) { 330 writer.println("None"); 331 } else { 332 for (int i = 0; i < mTargets.size(); i++) { 333 writer.println("[" + i + "]"); 334 writer.increaseIndent(); 335 mTargets.get(i).dump(writer); 336 writer.decreaseIndent(); 337 } 338 writer.decreaseIndent(); 339 } 340 writer.decreaseIndent(); 341 } 342 } 343