1 package com.android.compatibility.common.util; 2 /* 3 * Copyright (C) 2022 The Android Open Source Project 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18 19 import static org.junit.Assert.assertNotNull; 20 21 import android.graphics.Rect; 22 import android.os.Build; 23 import android.util.Log; 24 import android.util.TypedValue; 25 26 import androidx.test.core.app.ApplicationProvider; 27 import androidx.test.platform.app.InstrumentationRegistry; 28 import androidx.test.uiautomator.By; 29 import androidx.test.uiautomator.BySelector; 30 import androidx.test.uiautomator.Direction; 31 import androidx.test.uiautomator.StaleObjectException; 32 import androidx.test.uiautomator.UiDevice; 33 import androidx.test.uiautomator.UiObject2; 34 import androidx.test.uiautomator.UiObjectNotFoundException; 35 import androidx.test.uiautomator.UiScrollable; 36 import androidx.test.uiautomator.UiSelector; 37 import androidx.test.uiautomator.Until; 38 39 import java.util.List; 40 import java.util.Objects; 41 import java.util.regex.Pattern; 42 43 public class UiAutomatorUtils2 { UiAutomatorUtils2()44 private UiAutomatorUtils2() {} 45 46 private static final String LOG_TAG = "UiAutomatorUtils2"; 47 48 // A flaky test can enable logging of ui dump when searching the item on screen. 49 public static final boolean DEBUG_UI_DUMP = false; 50 51 /** Default swipe deadzone percentage. See {@link UiScrollable}. */ 52 private static final double DEFAULT_SWIPE_DEADZONE_PCT_TV = 0.1f; 53 private static final double DEFAULT_SWIPE_DEADZONE_PCT_ALL = 0.25f; 54 /** 55 * On Wear, some cts tests like CtsPermissionUiTestCases that run on 56 * low performance device. Keep 0.05 to have better matching. 57 */ 58 private static final double DEFAULT_SWIPE_DEADZONE_PCT_WEAR = 0.05f; 59 60 /** Minimum view height accepted (before needing to scroll more). */ 61 private static final float MIN_VIEW_HEIGHT_DP = 8; 62 63 private static Pattern sCollapsingToolbarResPattern = 64 Pattern.compile(".*:id/collapsing_toolbar"); 65 66 private static final UserHelper USER_HELPER = new UserHelper(); 67 getUiDevice()68 public static UiDevice getUiDevice() { 69 return UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()); 70 } 71 convertDpToPx(float dp)72 private static int convertDpToPx(float dp) { 73 return Math.round(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, 74 ApplicationProvider.getApplicationContext().getResources().getDisplayMetrics())); 75 } 76 getSwipeDeadZonePct()77 private static double getSwipeDeadZonePct() { 78 if (FeatureUtil.isTV()) { 79 return DEFAULT_SWIPE_DEADZONE_PCT_TV; 80 } else if (FeatureUtil.isWatch()) { 81 return DEFAULT_SWIPE_DEADZONE_PCT_WEAR; 82 } else { 83 return DEFAULT_SWIPE_DEADZONE_PCT_ALL; 84 } 85 } 86 waitUntilObjectGone(BySelector selector)87 public static void waitUntilObjectGone(BySelector selector) { 88 waitUntilObjectGone(selector, 20_000); 89 } 90 waitUntilObjectGone(BySelector selector, long timeoutMs)91 public static void waitUntilObjectGone(BySelector selector, long timeoutMs) { 92 try { 93 if (getUiDevice().wait(Until.gone(selector), timeoutMs)) { 94 return; 95 } 96 } catch (StaleObjectException exception) { 97 // UiDevice.wait() may cause StaleObjectException if the {@link View} attached to 98 // UiObject2 is no longer in the view tree. 99 return; 100 } 101 102 throw new RuntimeException("view " + selector + " is still visible after " + timeoutMs 103 + "ms"); 104 } 105 106 // Will wrap any asserting exceptions thrown by the parameter with a UI dump assertWithUiDump(ThrowingRunnable assertion)107 public static void assertWithUiDump(ThrowingRunnable assertion) { 108 ExceptionUtils.wrappingExceptions(UiDumpUtils::wrapWithUiDump, assertion); 109 } 110 waitFindObject(BySelector selector)111 public static UiObject2 waitFindObject(BySelector selector) throws UiObjectNotFoundException { 112 return waitFindObject(selector, 20_000); 113 } 114 waitFindObject(BySelector selector, long timeoutMs)115 public static UiObject2 waitFindObject(BySelector selector, long timeoutMs) 116 throws UiObjectNotFoundException { 117 final UiObject2 view = waitFindObjectOrNull(selector, timeoutMs); 118 ExceptionUtils.wrappingExceptions(UiDumpUtils::wrapWithUiDump, () -> { 119 assertNotNull("View not found after waiting for " + timeoutMs + "ms: " + selector, 120 view); 121 }); 122 return view; 123 } 124 waitFindObjectOrNull(BySelector selector)125 public static UiObject2 waitFindObjectOrNull(BySelector selector) 126 throws UiObjectNotFoundException { 127 return waitFindObjectOrNull(selector, 20_000); 128 } 129 waitFindObjectOrNull(BySelector selector, long timeoutMs)130 public static UiObject2 waitFindObjectOrNull(BySelector selector, long timeoutMs) 131 throws UiObjectNotFoundException { 132 // If the target user is a visible background user, find the object on the main display 133 // assigned to the user. This is because UiScrollable does not support multi-display, 134 // so any scroll actions from UiScrollable will be performed on the default display, 135 // regardless of which display the test is running on. 136 // This is specifically for the tests that support secondary_user_on_secondary_display. 137 if (USER_HELPER.isVisibleBackgroundUser()) { 138 return waitFindObjectOrNullOnDisplay( 139 selector, timeoutMs, USER_HELPER.getMainDisplayId()); 140 } 141 UiObject2 view = null; 142 long start = System.currentTimeMillis(); 143 144 boolean isAtEnd = false; 145 boolean wasScrolledUpAlready = false; 146 boolean scrolledPastCollapsibleToolbar = false; 147 148 final int minViewHeightPx = convertDpToPx(MIN_VIEW_HEIGHT_DP); 149 150 int viewHeight = -1; 151 while (view == null && start + timeoutMs > System.currentTimeMillis()) { 152 try { 153 view = getUiDevice().wait(Until.findObject(selector), 1000); 154 if (view != null) { 155 viewHeight = view.getVisibleBounds().height(); 156 } 157 } catch (StaleObjectException exception) { 158 // UiDevice.wait() or view.getVisibleBounds() may cause StaleObjectException if 159 // the {@link View} attached to UiObject2 is no longer in the view tree. 160 Log.v(LOG_TAG, "UiObject2 view is no longer in the view tree.", exception); 161 view = null; 162 getUiDevice().waitForIdle(); 163 continue; 164 } 165 if (DEBUG_UI_DUMP) { 166 StringBuilder sb = new StringBuilder(); 167 UiDumpUtils.dumpNodes(sb); 168 Log.d(LOG_TAG, selector + " " + (view == null ? "not found" : "found") 169 + " on the screen. \n" + sb); 170 } 171 if (view == null || viewHeight < minViewHeightPx) { 172 if (view == null) { 173 Log.v(LOG_TAG, selector + " not found on the screen, try scrolling."); 174 } 175 final double deadZone = getSwipeDeadZonePct(); 176 UiScrollable scrollable = new UiScrollable(new UiSelector().scrollable(true)); 177 scrollable.setSwipeDeadZonePercentage(deadZone); 178 if (scrollable.exists()) { 179 if (!scrolledPastCollapsibleToolbar) { 180 scrollPastCollapsibleToolbar(scrollable, deadZone); 181 scrolledPastCollapsibleToolbar = true; 182 continue; 183 } 184 if (isAtEnd) { 185 if (wasScrolledUpAlready) { 186 return null; 187 } 188 scrollable.scrollToBeginning(Integer.MAX_VALUE); 189 isAtEnd = false; 190 wasScrolledUpAlready = true; 191 scrolledPastCollapsibleToolbar = false; 192 } else { 193 Rect boundsBeforeScroll = scrollable.getBounds(); 194 boolean scrollAtStartOrEnd; 195 boolean isWearCompose = FeatureUtil.isWatch() && Objects.equals( 196 scrollable.getPackageName(), 197 InstrumentationRegistry.getInstrumentation().getContext() 198 .getPackageManager().getPermissionControllerPackageName()); 199 if (isWearCompose) { 200 // TODO(b/306483780): Removed the condition once the scrollForward is 201 // fixed. 202 if (!wasScrolledUpAlready) { 203 // TODO(b/306483780): scrollForward() always returns false. Thus 204 // `isAtEnd` will never be false for Wear Compose, because 205 // `scrollAtStartOrEnd` is set to false, and the value of `isAtEnd` 206 // is an && combination of that value. To avoid skipping Views 207 // that exist above the start-point of the search, we will first 208 // scroll up before doing a downward search and scroll. 209 scrollable.scrollToBeginning(Integer.MAX_VALUE); 210 wasScrolledUpAlready = true; 211 continue; 212 } 213 scrollable.scrollForward(); 214 scrollAtStartOrEnd = false; 215 } else { 216 Log.v(LOG_TAG, "Scrolling boundsBeforeScroll: " + boundsBeforeScroll); 217 scrollAtStartOrEnd = !scrollable.scrollForward(); 218 } 219 // The scrollable view may no longer be scrollable after the toolbar is 220 // collapsed. 221 if (scrollable.exists()) { 222 Rect boundsAfterScroll = scrollable.getBounds(); 223 isAtEnd = scrollAtStartOrEnd && boundsBeforeScroll.equals( 224 boundsAfterScroll); 225 Log.v(LOG_TAG, "Scrolling done, boundsAfterScroll: " 226 + boundsAfterScroll); 227 } else { 228 isAtEnd = scrollAtStartOrEnd; 229 } 230 Log.v(LOG_TAG, "Scrolling done, scrollAtStartOrEnd=" + scrollAtStartOrEnd 231 + ", isAtEnd=" + isAtEnd); 232 } 233 } else { 234 Log.v(LOG_TAG, "There might be a collapsing toolbar, but no scrollable view." 235 + " Try to collapse"); 236 // There might be a collapsing toolbar, but no scrollable view. Try to collapse 237 scrollPastCollapsibleToolbar(null, deadZone); 238 } 239 } 240 } 241 return view; 242 } 243 244 /** 245 * Finds the object on the given display. 246 * 247 * @param selector The selector to match. 248 * @param timeoutMs The timeout in milliseconds. 249 * @param displayId The display to search on. 250 * @return The object that matches the selector, or null if not found. 251 */ waitFindObjectOrNullOnDisplay( BySelector selector, long timeoutMs, int displayId)252 public static UiObject2 waitFindObjectOrNullOnDisplay( 253 BySelector selector, long timeoutMs, int displayId) throws UiObjectNotFoundException { 254 // Only supported in API level 30 or higher versions. 255 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { 256 return null; 257 } 258 259 UiObject2 view = null; 260 long start = System.currentTimeMillis(); 261 262 while (view == null && start + timeoutMs > System.currentTimeMillis()) { 263 view = getUiDevice().wait(Until.findObject(selector), 1000); 264 if (view != null) { 265 break; 266 } 267 268 List<UiObject2> scrollableViews = getUiDevice().findObjects( 269 By.displayId(displayId).scrollable(true)); 270 if (scrollableViews != null && !scrollableViews.isEmpty()) { 271 for (int i = 0; i < scrollableViews.size(); i++) { 272 UiObject2 scrollableView = scrollableViews.get(i); 273 // Swipe far away from the edges to avoid triggering navigation gestures 274 scrollableView.setGestureMarginPercentage((float) getSwipeDeadZonePct()); 275 // Scroll from the top to the bottom until the view object is found. 276 scrollableView.scroll(Direction.UP, 1.0f); 277 scrollableView.scrollUntil(Direction.DOWN, Until.findObject(selector)); 278 view = getUiDevice().findObject(selector); 279 if (view != null) { 280 break; 281 } 282 } 283 } else { 284 // There might be a collapsing toolbar, but no scrollable view. Try to collapse 285 final double deadZone = getSwipeDeadZonePct(); 286 scrollPastCollapsibleToolbar(null, deadZone); 287 } 288 } 289 return view; 290 } 291 scrollPastCollapsibleToolbar(UiScrollable scrollable, double deadZone)292 private static void scrollPastCollapsibleToolbar(UiScrollable scrollable, double deadZone) 293 throws UiObjectNotFoundException { 294 final UiObject2 collapsingToolbar = getUiDevice().findObject( 295 By.res(sCollapsingToolbarResPattern)); 296 if (collapsingToolbar == null) { 297 Log.v(LOG_TAG, "collapsingToolbar is null, return"); 298 return; 299 } 300 301 final int steps = 55; // == UiScrollable.SCROLL_STEPS 302 if (scrollable != null && scrollable.exists()) { 303 final Rect scrollableBounds = scrollable.getVisibleBounds(); 304 final int distanceToSwipe = collapsingToolbar.getVisibleBounds().height() / 2; 305 boolean result = getUiDevice().swipe(scrollableBounds.centerX(), 306 scrollableBounds.centerY(), 307 scrollableBounds.centerX(), scrollableBounds.centerY() - distanceToSwipe, 308 steps); 309 Log.v(LOG_TAG, "scrollPastCollapsibleToolbar swipe successful = " + result); 310 } else { 311 // There might be a collapsing toolbar, but no scrollable view. Try to collapse 312 int maxY = getUiDevice().getDisplayHeight(); 313 int minY = (int) (deadZone * maxY); 314 maxY -= minY; 315 boolean result = getUiDevice().drag(0, maxY, 0, minY, steps); 316 Log.v(LOG_TAG, "scrollPastCollapsibleToolbar drag successful = " + result); 317 } 318 } 319 } 320