1 /* <lambda>null2 * Copyright (C) 2022 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 package android.healthconnect.cts.lib 17 18 import android.Manifest 19 import android.Manifest.permission.REVOKE_RUNTIME_PERMISSIONS 20 import android.content.Context 21 import android.content.pm.PackageManager 22 import android.content.pm.PackageManager.PERMISSION_DENIED 23 import android.content.pm.PackageManager.PERMISSION_GRANTED 24 import android.health.connect.datatypes.* 25 import android.health.connect.datatypes.units.Length 26 import android.os.SystemClock 27 import android.util.Log 28 import androidx.test.uiautomator.* 29 import com.android.compatibility.common.util.SystemUtil.runWithShellPermissionIdentity 30 import com.android.compatibility.common.util.UiAutomatorUtils2.* 31 import com.android.compatibility.common.util.UiDumpUtils 32 import java.time.Duration 33 import java.time.Instant 34 import java.util.concurrent.TimeoutException 35 36 /** UI testing helper. */ 37 object UiTestUtils { 38 39 /** The label of the rescan button. */ 40 const val RESCAN_BUTTON_LABEL = "Scan device" 41 42 private val WAIT_TIMEOUT = Duration.ofSeconds(5) 43 private val NOT_DISPLAYED_TIMEOUT = Duration.ofMillis(500) 44 private val FIND_OBJECT_TIMEOUT = Duration.ofMillis(500) 45 private val NEW_WINDOW_TIMEOUT_MILLIS = 3000L 46 private val RETRY_TIMEOUT_MILLIS = 5000L 47 48 private val TAG = UiTestUtils::class.java.simpleName 49 50 private val TEST_DEVICE: Device = 51 Device.Builder().setManufacturer("google").setModel("Pixel").setType(1).build() 52 53 private val PACKAGE_NAME = "android.healthconnect.cts.ui" 54 55 const val TEST_APP_PACKAGE_NAME = "android.healthconnect.cts.app" 56 57 const val TEST_APP_2_PACKAGE_NAME = "android.healthconnect.cts.app2" 58 59 const val TEST_APP_NAME = "Health Connect cts test app" 60 61 private const val MASK_PERMISSION_FLAGS = 62 (PackageManager.FLAG_PERMISSION_USER_SET or 63 PackageManager.FLAG_PERMISSION_USER_FIXED or 64 PackageManager.FLAG_PERMISSION_AUTO_REVOKED) 65 66 /** 67 * Waits for the given [selector] to be displayed and performs the given [uiObjectAction] on it. 68 * 69 * If the object is not visible attempts to find the object by scrolling down while possible. If 70 * scrolling reached the bottom attempts to find the object by scrolling up. 71 * 72 * @throws AssertionError if the object can't be found within [waitTimeout] 73 */ 74 fun waitDisplayed( 75 selector: BySelector, 76 waitTimeout: Duration = WAIT_TIMEOUT, 77 uiObjectAction: (UiObject2) -> Unit = {}, 78 ) { 79 waitFor("$selector to be displayed", waitTimeout) { 80 uiObjectAction(waitFindObject(selector, it.toMillis())) 81 true 82 } 83 } 84 85 /** 86 * Returns an object if it's visible on the screen or returns null otherwise. 87 * 88 * This method does _not_ scroll in an attempt to find the object. 89 */ 90 private fun findObjectOrNull( 91 selector: BySelector, 92 timeout: Duration = FIND_OBJECT_TIMEOUT, 93 ): UiObject2? { 94 return getUiDevice().wait(Until.findObject(selector), timeout.toMillis()) 95 } 96 97 /** 98 * Returns an object if it's visible on the screen or throws otherwise. 99 * 100 * Use this if the object is expected to be visible on the screen without scrolling. 101 */ 102 fun findObject(selector: BySelector, timeout: Duration = FIND_OBJECT_TIMEOUT): UiObject2 { 103 return findObjectOrNull(selector, timeout) 104 ?: throw objectNotFoundExceptionWithDump("Object not found $selector") 105 } 106 107 /** 108 * Clicks on an object if it's visible on the screen or throws otherwise. 109 * 110 * Use this if the object is expected to be visible on the screen without scrolling. 111 */ 112 fun findObjectAndClick(selector: BySelector) { 113 findObject(selector).click() 114 getUiDevice().waitForIdle() 115 } 116 117 fun clickOnDescAndWaitForNewWindow(text: String) { 118 findDesc(text).clickAndWait(Until.newWindow(), NEW_WINDOW_TIMEOUT_MILLIS) 119 } 120 121 fun clickOnTextAndWaitForNewWindow(text: String) { 122 findText(text).clickAndWait(Until.newWindow(), NEW_WINDOW_TIMEOUT_MILLIS) 123 } 124 125 fun navigateToNewPage(text: String) { 126 scrollDownToAndFindText(text) 127 clickOnTextAndWaitForNewWindow(text) 128 } 129 130 fun navigateToSeeAppData(appName: String) { 131 navigateToAppPermissions(appName) 132 navigateToNewPage("See app data") 133 } 134 135 fun navigateToAppPermissions(appName: String) { 136 navigateToNewPage("App permissions") 137 navigateToNewPage(appName) 138 } 139 140 /** 141 * Returns an object with given text if it's visible on the screen or throws otherwise. 142 * 143 * Use this if the text label is expected to be visible on the screen without scrolling. 144 */ 145 fun findText(text: String): UiObject2 { 146 return findObject(By.text(text)) 147 } 148 149 /** 150 * Returns an object that contains given text if it's visible on the screen or throws otherwise. 151 * 152 * Use this if the text label is expected to be visible on the screen without scrolling. 153 */ 154 fun findTextContains(text: String): UiObject2 { 155 return findObject(By.textContains(text)) 156 } 157 158 /** 159 * Clicks on a text label if it's visible on the screen or throws otherwise. 160 * 161 * Use this if the text label is expected to be visible on the screen without scrolling. 162 */ 163 fun findTextAndClick(text: String) { 164 findObjectAndClick(By.text(text)) 165 } 166 167 /** 168 * Returns an object with given content description if it's visible on the screen. 169 * 170 * Throws if the object is not visible. 171 * 172 * Use this if the text label is expected to be visible on the screen without scrolling. 173 */ 174 fun findDesc(desc: String): UiObject2 { 175 return findObject(By.desc(desc)) 176 } 177 178 /** 179 * Clicks on an object with give content description if it's visible on the screen. 180 * 181 * Throws if the object is not visible. 182 * 183 * Use this if the object is expected to be visible on the screen without scrolling. 184 */ 185 fun findDescAndClick(desc: String) { 186 findObjectAndClick(By.desc(desc)) 187 } 188 189 /** Throws an exception if given object is visible on the screen. */ 190 fun verifyObjectNotFound(selector: BySelector) { 191 if (findObjectOrNull(selector) != null) { 192 throw AssertionError("assertObjectNotFound: did not expect object $selector") 193 } 194 } 195 196 /** Throws an exception if given text label is visible on the screen. */ 197 fun verifyTextNotFound(text: String) { 198 verifyObjectNotFound(By.text(text)) 199 } 200 201 /** 202 * Waits for given object to become non visible on the screen. 203 * 204 * @throws TimeoutException if the object is visible on the screen after [timeout]. 205 */ 206 fun waitForObjectNotFound(selector: BySelector, timeout: Duration = NOT_DISPLAYED_TIMEOUT) { 207 waitFor("$selector not to be found", timeout) { findObjectOrNull(selector) == null } 208 } 209 210 /** Quickly scrolls down to the bottom. */ 211 fun scrollToEnd() { 212 val scrollable = UiScrollable(UiSelector().scrollable(true)) 213 if (!scrollable.waitForExists(FIND_OBJECT_TIMEOUT.toMillis())) { 214 // Scrollable either doesn't exist or the view fully fits inside the screen. 215 return 216 } 217 scrollable.flingToEnd(Integer.MAX_VALUE) 218 } 219 220 fun scrollDownTo(selector: BySelector) { 221 val scrollable = waitFindObjectOrNull(By.scrollable(true), FIND_OBJECT_TIMEOUT.toMillis()) 222 223 scrollable?.scrollUntil(Direction.DOWN, Until.findObject(selector)) 224 findObject(selector) 225 } 226 227 fun scrollUpTo(selector: BySelector) { 228 waitFindObject(By.scrollable(true)).scrollUntil(Direction.UP, Until.findObject(selector)) 229 } 230 231 fun scrollDownToAndClick(selector: BySelector) { 232 try { 233 waitDisplayed(selector) { it.click() } 234 } catch (e: Exception) { 235 val scrollable = getUiDevice().findObject(By.scrollable(true)) 236 237 if (scrollable == null) { 238 throw objectNotFoundExceptionWithDump( 239 "Scrollable not found while trying to find $selector" 240 ) 241 } 242 243 val obj = scrollable.scrollUntil(Direction.DOWN, Until.findObject(selector)) 244 245 findObject(selector) 246 247 obj.click() 248 } 249 getUiDevice().waitForIdle() 250 } 251 252 fun scrollDownToAndFindText(text: String) { 253 scrollDownTo(By.text(text)) 254 findText(text) 255 } 256 257 fun scrollDownToAndFindTextContains(text: String) { 258 scrollDownTo(By.textContains(text)) 259 findTextContains(text) 260 } 261 262 fun skipOnboardingIfAppears() { 263 getUiDevice().waitForIdle() 264 265 val getStartedButton = 266 findObjectWithRetry({ _ -> findObjectOrNull(By.text("Get started")) }) 267 if (getStartedButton != null) { 268 clickOnTextAndWaitForNewWindow("Get started") 269 } else { 270 val getStartedButton2 = 271 findObjectWithRetry({ _ -> findObjectOrNull(By.text("GET STARTED")) }) 272 if (getStartedButton2 != null) { 273 clickOnTextAndWaitForNewWindow("GET STARTED") 274 } else { 275 Log.i(TAG, "No onboarding button found!") 276 } 277 } 278 } 279 280 /** Clicks on [UiObject2] with given [text]. */ 281 fun clickOnText(string: String) { 282 waitDisplayed(By.text(string)) { it.click() } 283 } 284 285 fun navigateBackToHomeScreen() { 286 while (isNotDisplayed("Permissions and data")) { 287 try { 288 waitDisplayed(By.desc("Navigate up")) 289 clickOnContentDescription("Navigate up") 290 } catch (e: Exception) { 291 break 292 } 293 } 294 } 295 296 private fun isNotDisplayed(text: String): Boolean { 297 try { 298 waitNotDisplayed(By.text(text)) 299 return true 300 } catch (e: Exception) { 301 return false 302 } 303 } 304 305 /** Clicks on [UiObject2] with given [string] content description. */ 306 fun clickOnContentDescription(string: String) { 307 waitDisplayed(By.desc(string)) { it.click() } 308 } 309 310 /** Waits for the given [selector] not to be displayed. */ 311 fun waitNotDisplayed(selector: BySelector, timeout: Duration = NOT_DISPLAYED_TIMEOUT) { 312 waitFor("$selector not to be displayed", timeout) { 313 waitFindObjectOrNull(selector, it.toMillis()) == null 314 } 315 } 316 317 fun UiDevice.rotate() { 318 unfreezeRotation() 319 if (isNaturalOrientation) { 320 setOrientationLeft() 321 } else { 322 setOrientationNatural() 323 } 324 freezeRotation() 325 waitForIdle() 326 } 327 328 private fun findObjectWithRetry( 329 automatorMethod: (timeoutMillis: Long) -> UiObject2?, 330 timeoutMillis: Long = RETRY_TIMEOUT_MILLIS, 331 ): UiObject2? { 332 val startTime = SystemClock.elapsedRealtime() 333 return try { 334 automatorMethod(timeoutMillis) 335 } catch (e: StaleObjectException) { 336 val remainingTime = timeoutMillis - (SystemClock.elapsedRealtime() - startTime) 337 if (remainingTime <= 0) { 338 throw e 339 } 340 automatorMethod(remainingTime) 341 } 342 } 343 344 private fun waitFor( 345 message: String, 346 uiAutomatorConditionTimeout: Duration, 347 uiAutomatorCondition: (Duration) -> Boolean, 348 ) { 349 val elapsedStartMillis = SystemClock.elapsedRealtime() 350 while (true) { 351 getUiDevice().waitForIdle() 352 val durationSinceStart = 353 Duration.ofMillis(SystemClock.elapsedRealtime() - elapsedStartMillis) 354 if (durationSinceStart >= uiAutomatorConditionTimeout) { 355 break 356 } 357 val remainingTime = uiAutomatorConditionTimeout - durationSinceStart 358 val uiAutomatorTimeout = minOf(uiAutomatorConditionTimeout, remainingTime) 359 try { 360 if (uiAutomatorCondition(uiAutomatorTimeout)) { 361 return 362 } else { 363 Log.d(TAG, "Failed condition for $message, will retry if within timeout") 364 } 365 } catch (e: StaleObjectException) { 366 Log.d(TAG, "StaleObjectException for $message, will retry if within timeout", e) 367 } 368 } 369 370 throw TimeoutException("Timed out waiting for $message") 371 } 372 373 private fun objectNotFoundExceptionWithDump(message: String): Exception { 374 return UiDumpUtils.wrapWithUiDump(UiObjectNotFoundException(message)) 375 } 376 377 fun stepsRecordFromTestApp(): StepsRecord { 378 return stepsRecord(TEST_APP_PACKAGE_NAME, /* stepCount= */ 10) 379 } 380 381 fun stepsRecordFromTestApp(stepCount: Long): StepsRecord { 382 return stepsRecord(TEST_APP_PACKAGE_NAME, stepCount) 383 } 384 385 fun stepsRecordFromTestApp(startTime: Instant): StepsRecord { 386 return stepsRecord( 387 TEST_APP_PACKAGE_NAME, 388 /* stepCount= */ 10, 389 startTime, 390 startTime.plusSeconds(100), 391 ) 392 } 393 394 fun stepsRecordFromTestApp(stepCount: Long, startTime: Instant): StepsRecord { 395 return stepsRecord(TEST_APP_PACKAGE_NAME, stepCount, startTime, startTime.plusSeconds(100)) 396 } 397 398 fun stepsRecordFromTestApp2(): StepsRecord { 399 return stepsRecord(TEST_APP_2_PACKAGE_NAME, /* stepCount= */ 10) 400 } 401 402 fun distanceRecordFromTestApp(): DistanceRecord { 403 return distanceRecord(TEST_APP_PACKAGE_NAME) 404 } 405 406 fun distanceRecordFromTestApp(startTime: Instant): DistanceRecord { 407 return distanceRecord(TEST_APP_PACKAGE_NAME, startTime, startTime.plusSeconds(100)) 408 } 409 410 fun distanceRecordFromTestApp2(): DistanceRecord { 411 return distanceRecord(TEST_APP_2_PACKAGE_NAME) 412 } 413 414 private fun stepsRecord(packageName: String, stepCount: Long): StepsRecord { 415 return stepsRecord(packageName, stepCount, Instant.now().minusMillis(1000), Instant.now()) 416 } 417 418 private fun stepsRecord( 419 packageName: String, 420 stepCount: Long, 421 startTime: Instant, 422 endTime: Instant, 423 ): StepsRecord { 424 val dataOrigin: DataOrigin = DataOrigin.Builder().setPackageName(packageName).build() 425 val testMetadataBuilder: Metadata.Builder = Metadata.Builder() 426 testMetadataBuilder.setDevice(TEST_DEVICE).setDataOrigin(dataOrigin) 427 testMetadataBuilder.setClientRecordId("SR" + Math.random()) 428 return StepsRecord.Builder(testMetadataBuilder.build(), startTime, endTime, stepCount) 429 .build() 430 } 431 432 private fun distanceRecord( 433 packageName: String, 434 startTime: Instant, 435 endTime: Instant, 436 ): DistanceRecord { 437 val dataOrigin: DataOrigin = DataOrigin.Builder().setPackageName(packageName).build() 438 val testMetadataBuilder: Metadata.Builder = Metadata.Builder() 439 testMetadataBuilder.setDevice(TEST_DEVICE).setDataOrigin(dataOrigin) 440 testMetadataBuilder.setClientRecordId("SR" + Math.random()) 441 return DistanceRecord.Builder( 442 testMetadataBuilder.build(), 443 startTime, 444 endTime, 445 Length.fromMeters(500.0), 446 ) 447 .build() 448 } 449 450 private fun distanceRecord(packageName: String): DistanceRecord { 451 val dataOrigin: DataOrigin = DataOrigin.Builder().setPackageName(packageName).build() 452 val testMetadataBuilder: Metadata.Builder = Metadata.Builder() 453 testMetadataBuilder.setDevice(TEST_DEVICE).setDataOrigin(dataOrigin) 454 testMetadataBuilder.setClientRecordId("SR" + Math.random()) 455 return DistanceRecord.Builder( 456 testMetadataBuilder.build(), 457 Instant.now().minusMillis(1000), 458 Instant.now(), 459 Length.fromMeters(500.0), 460 ) 461 .build() 462 } 463 464 fun grantPermissionViaPackageManager(context: Context, packageName: String, permName: String) { 465 val pm = context.packageManager 466 if (pm.checkPermission(permName, packageName) == PERMISSION_GRANTED) { 467 return 468 } 469 runWithShellPermissionIdentity( 470 { pm.grantRuntimePermission(packageName, permName, context.user) }, 471 Manifest.permission.GRANT_RUNTIME_PERMISSIONS, 472 ) 473 } 474 475 fun revokePermissionViaPackageManager(context: Context, packageName: String, permName: String) { 476 val pm = context.packageManager 477 478 if (pm.checkPermission(permName, packageName) == PERMISSION_DENIED) { 479 runWithShellPermissionIdentity( 480 { 481 pm.updatePermissionFlags( 482 permName, 483 packageName, 484 MASK_PERMISSION_FLAGS, 485 PackageManager.FLAG_PERMISSION_USER_SET, 486 context.user, 487 ) 488 }, 489 REVOKE_RUNTIME_PERMISSIONS, 490 ) 491 return 492 } 493 runWithShellPermissionIdentity( 494 { pm.revokeRuntimePermission(packageName, permName, context.user, /* reason= */ "") }, 495 REVOKE_RUNTIME_PERMISSIONS, 496 ) 497 } 498 499 fun setFont(device: UiDevice) { 500 with(device) { executeShellCommand("shell settings put system font_scale 0.85") } 501 } 502 } 503