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 = "UiAutomatorUtils"; 47 48 /** Default swipe deadzone percentage. See {@link UiScrollable}. */ 49 private static final double DEFAULT_SWIPE_DEADZONE_PCT_TV = 0.1f; 50 private static final double DEFAULT_SWIPE_DEADZONE_PCT_ALL = 0.25f; 51 /** 52 * On Wear, some cts tests like CtsPermissionUiTestCases that run on 53 * low performance device. Keep 0.05 to have better matching. 54 */ 55 private static final double DEFAULT_SWIPE_DEADZONE_PCT_WEAR = 0.05f; 56 57 /** Minimum view height accepted (before needing to scroll more). */ 58 private static final float MIN_VIEW_HEIGHT_DP = 8; 59 60 private static Pattern sCollapsingToolbarResPattern = 61 Pattern.compile(".*:id/collapsing_toolbar"); 62 63 private static final UserHelper USER_HELPER = new UserHelper(); 64 getUiDevice()65 public static UiDevice getUiDevice() { 66 return UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()); 67 } 68 convertDpToPx(float dp)69 private static int convertDpToPx(float dp) { 70 return Math.round(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, 71 ApplicationProvider.getApplicationContext().getResources().getDisplayMetrics())); 72 } 73 getSwipeDeadZonePct()74 private static double getSwipeDeadZonePct() { 75 if (FeatureUtil.isTV()) { 76 return DEFAULT_SWIPE_DEADZONE_PCT_TV; 77 } else if (FeatureUtil.isWatch()) { 78 return DEFAULT_SWIPE_DEADZONE_PCT_WEAR; 79 } else { 80 return DEFAULT_SWIPE_DEADZONE_PCT_ALL; 81 } 82 } 83 waitUntilObjectGone(BySelector selector)84 public static void waitUntilObjectGone(BySelector selector) { 85 waitUntilObjectGone(selector, 20_000); 86 } 87 waitUntilObjectGone(BySelector selector, long timeoutMs)88 public static void waitUntilObjectGone(BySelector selector, long timeoutMs) { 89 try { 90 if (getUiDevice().wait(Until.gone(selector), timeoutMs)) { 91 return; 92 } 93 } catch (StaleObjectException exception) { 94 // UiDevice.wait() may cause StaleObjectException if the {@link View} attached to 95 // UiObject2 is no longer in the view tree. 96 return; 97 } 98 99 throw new RuntimeException("view " + selector + " is still visible after " + timeoutMs 100 + "ms"); 101 } 102 103 // Will wrap any asserting exceptions thrown by the parameter with a UI dump assertWithUiDump(ThrowingRunnable assertion)104 public static void assertWithUiDump(ThrowingRunnable assertion) { 105 ExceptionUtils.wrappingExceptions(UiDumpUtils::wrapWithUiDump, assertion); 106 } 107 waitFindObject(BySelector selector)108 public static UiObject2 waitFindObject(BySelector selector) throws UiObjectNotFoundException { 109 return waitFindObject(selector, 20_000); 110 } 111 waitFindObject(BySelector selector, long timeoutMs)112 public static UiObject2 waitFindObject(BySelector selector, long timeoutMs) 113 throws UiObjectNotFoundException { 114 final UiObject2 view = waitFindObjectOrNull(selector, timeoutMs); 115 ExceptionUtils.wrappingExceptions(UiDumpUtils::wrapWithUiDump, () -> { 116 assertNotNull("View not found after waiting for " + timeoutMs + "ms: " + selector, 117 view); 118 }); 119 return view; 120 } 121 waitFindObjectOrNull(BySelector selector)122 public static UiObject2 waitFindObjectOrNull(BySelector selector) 123 throws UiObjectNotFoundException { 124 return waitFindObjectOrNull(selector, 20_000); 125 } 126 waitFindObjectOrNull(BySelector selector, long timeoutMs)127 public static UiObject2 waitFindObjectOrNull(BySelector selector, long timeoutMs) 128 throws UiObjectNotFoundException { 129 // If the target user is a visible background user, find the object on the main display 130 // assigned to the user. This is because UiScrollable does not support multi-display, 131 // so any scroll actions from UiScrollable will be performed on the default display, 132 // regardless of which display the test is running on. 133 // This is specifically for the tests that support secondary_user_on_secondary_display. 134 if (USER_HELPER.isVisibleBackgroundUser()) { 135 return waitFindObjectOrNullOnDisplay( 136 selector, timeoutMs, USER_HELPER.getMainDisplayId()); 137 } 138 UiObject2 view = null; 139 long start = System.currentTimeMillis(); 140 141 boolean isAtEnd = false; 142 boolean wasScrolledUpAlready = false; 143 boolean scrolledPastCollapsibleToolbar = false; 144 145 final int minViewHeightPx = convertDpToPx(MIN_VIEW_HEIGHT_DP); 146 147 int viewHeight = -1; 148 while (view == null && start + timeoutMs > System.currentTimeMillis()) { 149 try { 150 view = getUiDevice().wait(Until.findObject(selector), 1000); 151 if (view != null) { 152 viewHeight = view.getVisibleBounds().height(); 153 } 154 } catch (StaleObjectException exception) { 155 // UiDevice.wait() or view.getVisibleBounds() may cause StaleObjectException if 156 // the {@link View} attached to UiObject2 is no longer in the view tree. 157 Log.v(LOG_TAG, "UiObject2 view is no longer in the view tree.", exception); 158 view = null; 159 getUiDevice().waitForIdle(); 160 continue; 161 } 162 163 if (view == null || viewHeight < minViewHeightPx) { 164 final double deadZone = getSwipeDeadZonePct(); 165 UiScrollable scrollable = new UiScrollable(new UiSelector().scrollable(true)); 166 scrollable.setSwipeDeadZonePercentage(deadZone); 167 if (scrollable.exists()) { 168 if (!scrolledPastCollapsibleToolbar) { 169 scrollPastCollapsibleToolbar(scrollable, deadZone); 170 scrolledPastCollapsibleToolbar = true; 171 continue; 172 } 173 if (isAtEnd) { 174 if (wasScrolledUpAlready) { 175 return null; 176 } 177 scrollable.scrollToBeginning(Integer.MAX_VALUE); 178 isAtEnd = false; 179 wasScrolledUpAlready = true; 180 scrolledPastCollapsibleToolbar = false; 181 } else { 182 Rect boundsBeforeScroll = scrollable.getBounds(); 183 boolean scrollAtStartOrEnd; 184 boolean isWearCompose = FeatureUtil.isWatch() && Objects.equals( 185 scrollable.getPackageName(), 186 InstrumentationRegistry.getInstrumentation().getContext() 187 .getPackageManager().getPermissionControllerPackageName()); 188 if (isWearCompose) { 189 // TODO(b/306483780): Removed the condition once the scrollForward is 190 // fixed. 191 if (!wasScrolledUpAlready) { 192 // TODO(b/306483780): scrollForward() always returns false. Thus 193 // `isAtEnd` will never be false for Wear Compose, because 194 // `scrollAtStartOrEnd` is set to false, and the value of `isAtEnd` 195 // is an && combination of that value. To avoid skipping Views 196 // that exist above the start-point of the search, we will first 197 // scroll up before doing a downward search and scroll. 198 scrollable.scrollToBeginning(Integer.MAX_VALUE); 199 wasScrolledUpAlready = true; 200 continue; 201 } 202 scrollable.scrollForward(); 203 scrollAtStartOrEnd = false; 204 } else { 205 scrollAtStartOrEnd = !scrollable.scrollForward(); 206 } 207 // The scrollable view may no longer be scrollable after the toolbar is 208 // collapsed. 209 if (scrollable.exists()) { 210 Rect boundsAfterScroll = scrollable.getBounds(); 211 isAtEnd = scrollAtStartOrEnd && boundsBeforeScroll.equals( 212 boundsAfterScroll); 213 } else { 214 isAtEnd = scrollAtStartOrEnd; 215 } 216 } 217 } else { 218 // There might be a collapsing toolbar, but no scrollable view. Try to collapse 219 scrollPastCollapsibleToolbar(null, deadZone); 220 } 221 } 222 } 223 return view; 224 } 225 226 /** 227 * Finds the object on the given display. 228 * 229 * @param selector The selector to match. 230 * @param timeoutMs The timeout in milliseconds. 231 * @param displayId The display to search on. 232 * @return The object that matches the selector, or null if not found. 233 */ waitFindObjectOrNullOnDisplay( BySelector selector, long timeoutMs, int displayId)234 public static UiObject2 waitFindObjectOrNullOnDisplay( 235 BySelector selector, long timeoutMs, int displayId) throws UiObjectNotFoundException { 236 // Only supported in API level 30 or higher versions. 237 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { 238 return null; 239 } 240 241 UiObject2 view = null; 242 long start = System.currentTimeMillis(); 243 244 while (view == null && start + timeoutMs > System.currentTimeMillis()) { 245 view = getUiDevice().wait(Until.findObject(selector), 1000); 246 if (view != null) { 247 break; 248 } 249 250 List<UiObject2> scrollableViews = getUiDevice().findObjects( 251 By.displayId(displayId).scrollable(true)); 252 if (scrollableViews != null && !scrollableViews.isEmpty()) { 253 for (int i = 0; i < scrollableViews.size(); i++) { 254 UiObject2 scrollableView = scrollableViews.get(i); 255 // Swipe far away from the edges to avoid triggering navigation gestures 256 scrollableView.setGestureMarginPercentage((float) getSwipeDeadZonePct()); 257 // Scroll from the top to the bottom until the view object is found. 258 scrollableView.scroll(Direction.UP, 1.0f); 259 scrollableView.scrollUntil(Direction.DOWN, Until.findObject(selector)); 260 view = getUiDevice().findObject(selector); 261 if (view != null) { 262 break; 263 } 264 } 265 } else { 266 // There might be a collapsing toolbar, but no scrollable view. Try to collapse 267 final double deadZone = getSwipeDeadZonePct(); 268 scrollPastCollapsibleToolbar(null, deadZone); 269 } 270 } 271 return view; 272 } 273 scrollPastCollapsibleToolbar(UiScrollable scrollable, double deadZone)274 private static void scrollPastCollapsibleToolbar(UiScrollable scrollable, double deadZone) 275 throws UiObjectNotFoundException { 276 final UiObject2 collapsingToolbar = getUiDevice().findObject( 277 By.res(sCollapsingToolbarResPattern)); 278 if (collapsingToolbar == null) { 279 return; 280 } 281 282 final int steps = 55; // == UiScrollable.SCROLL_STEPS 283 if (scrollable != null && scrollable.exists()) { 284 final Rect scrollableBounds = scrollable.getVisibleBounds(); 285 final int distanceToSwipe = collapsingToolbar.getVisibleBounds().height() / 2; 286 getUiDevice().swipe(scrollableBounds.centerX(), scrollableBounds.centerY(), 287 scrollableBounds.centerX(), scrollableBounds.centerY() - distanceToSwipe, 288 steps); 289 } else { 290 // There might be a collapsing toolbar, but no scrollable view. Try to collapse 291 int maxY = getUiDevice().getDisplayHeight(); 292 int minY = (int) (deadZone * maxY); 293 maxY -= minY; 294 getUiDevice().drag(0, maxY, 0, minY, steps); 295 } 296 } 297 } 298