1 /* 2 * 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 java.time.Duration 32 import java.time.Instant 33 import java.util.concurrent.TimeoutException 34 import java.util.regex.Pattern 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 45 private val TAG = UiTestUtils::class.java.simpleName 46 47 private val TEST_DEVICE: Device = 48 Device.Builder().setManufacturer("google").setModel("Pixel").setType(1).build() 49 50 private val PACKAGE_NAME = "android.healthconnect.cts.ui" 51 52 const val TEST_APP_PACKAGE_NAME = "android.healthconnect.cts.app" 53 54 private val TEST_APP_2_PACKAGE_NAME = "android.healthconnect.cts.app2" 55 56 const val TEST_APP_NAME = "Health Connect cts test app" 57 58 private const val MASK_PERMISSION_FLAGS = 59 (PackageManager.FLAG_PERMISSION_USER_SET or 60 PackageManager.FLAG_PERMISSION_USER_FIXED or 61 PackageManager.FLAG_PERMISSION_AUTO_REVOKED) 62 63 /** 64 * Waits for the given [selector] to be displayed and performs the given [uiObjectAction] on it. 65 */ waitDisplayednull66 fun waitDisplayed( 67 selector: BySelector, 68 waitTimeout: Duration = WAIT_TIMEOUT, 69 uiObjectAction: (UiObject2) -> Unit = {} 70 ) { <lambda>null71 waitFor("$selector to be displayed", waitTimeout) { 72 uiObjectAction(waitFindObject(selector, it.toMillis())) 73 true 74 } 75 } 76 scrollDownTonull77 fun scrollDownTo(selector: BySelector) { 78 waitFindObject(By.scrollable(true)).scrollUntil(Direction.DOWN, Until.findObject(selector)) 79 } 80 scrollDownToAndClicknull81 fun scrollDownToAndClick(selector: BySelector) { 82 getUiDevice() 83 .findObject(By.scrollable(true)) 84 .scrollUntil(Direction.DOWN, Until.findObject(selector)) 85 .click() 86 getUiDevice().waitForIdle() 87 } 88 skipOnboardingIfAppearsnull89 fun skipOnboardingIfAppears() { 90 try { 91 clickOnText("Get started") 92 } catch (e: Exception) { 93 try { 94 clickOnText("GET STARTED") 95 } catch (e: Exception) { 96 // No-op if onboarding was not displayed. 97 } 98 } 99 } 100 101 /** Clicks on [UiObject2] with given [text]. */ clickOnTextnull102 fun clickOnText(string: String) { 103 waitDisplayed(By.text(string)) { it.click() } 104 } 105 106 /** Clicks on [UiObject2] if the description contains given [string]. */ clickOnDescContainsnull107 fun clickOnDescContains(string: String) { 108 waitDisplayed(By.descContains(string)) { it.click() } 109 } 110 deleteAllDataAndNavigateToHomeScreennull111 fun deleteAllDataAndNavigateToHomeScreen() { 112 navigateBackToHomeScreen() 113 clickOnText("Data and access") 114 clickOnText("Delete all data") 115 try { 116 clickOnText("Delete all data") 117 clickOnText("Next") 118 clickOnText("Delete") 119 clickOnText("Done") 120 } catch (e: Exception) { 121 // No-op if all data is already deleted and the delete button is disabled. 122 } 123 navigateBackToHomeScreen() 124 } 125 navigateBackToHomeScreennull126 fun navigateBackToHomeScreen() { 127 while (isNotDisplayed("Permissions and data")) { 128 try { 129 waitDisplayed(By.desc("Navigate up")) 130 clickOnContentDescription("Navigate up") 131 } catch (e: Exception) { 132 break 133 } 134 } 135 } 136 isNotDisplayednull137 private fun isNotDisplayed(text: String): Boolean { 138 try { 139 waitNotDisplayed(By.text(text)) 140 return true 141 } catch (e: Exception) { 142 return false 143 } 144 } 145 navigateUpnull146 fun navigateUp() { 147 clickOnContentDescription("Navigate up") 148 } 149 150 /** Clicks on [UiObject2] with given [string] content description. */ clickOnContentDescriptionnull151 fun clickOnContentDescription(string: String) { 152 waitDisplayed(By.desc(string)) { it.click() } 153 } 154 155 /** Waits for all the given [textToFind] to be displayed. */ waitAllTextDisplayednull156 fun waitAllTextDisplayed(vararg textToFind: CharSequence?) { 157 for (text in textToFind) { 158 if (text != null) waitDisplayed(By.text(text.toString())) 159 } 160 } 161 162 /** Waits for the given [selector] not to be displayed. */ waitNotDisplayednull163 fun waitNotDisplayed(selector: BySelector) { 164 waitFor("$selector not to be displayed", NOT_DISPLAYED_TIMEOUT) { 165 waitFindObjectOrNull(selector, it.toMillis()) == null 166 } 167 } 168 169 /** Waits for all the given [textToFind] not to be displayed. */ waitAllTextNotDisplayednull170 fun waitAllTextNotDisplayed(vararg textToFind: CharSequence?) { 171 for (text in textToFind) { 172 if (text != null) waitNotDisplayed(By.text(text.toString())) 173 } 174 } 175 176 /** Waits for a button with the given [label] not to be displayed. */ waitButtonNotDisplayednull177 fun waitButtonNotDisplayed(label: CharSequence) { 178 waitNotDisplayed(buttonSelector(label)) 179 } 180 rotatenull181 fun UiDevice.rotate() { 182 unfreezeRotation() 183 if (isNaturalOrientation) { 184 setOrientationLeft() 185 } else { 186 setOrientationNatural() 187 } 188 freezeRotation() 189 waitForIdle() 190 } 191 resetRotationnull192 fun UiDevice.resetRotation() { 193 if (!isNaturalOrientation) { 194 unfreezeRotation() 195 setOrientationNatural() 196 freezeRotation() 197 waitForIdle() 198 } 199 } 200 buttonSelectornull201 private fun buttonSelector(label: CharSequence): BySelector { 202 return By.clickable(true).text(Pattern.compile("$label|${label.toString().uppercase()}")) 203 } 204 waitFornull205 private fun waitFor( 206 message: String, 207 uiAutomatorConditionTimeout: Duration, 208 uiAutomatorCondition: (Duration) -> Boolean, 209 ) { 210 val elapsedStartMillis = SystemClock.elapsedRealtime() 211 while (true) { 212 getUiDevice().waitForIdle() 213 val durationSinceStart = 214 Duration.ofMillis(SystemClock.elapsedRealtime() - elapsedStartMillis) 215 if (durationSinceStart >= uiAutomatorConditionTimeout) { 216 break 217 } 218 val remainingTime = uiAutomatorConditionTimeout - durationSinceStart 219 val uiAutomatorTimeout = minOf(uiAutomatorConditionTimeout, remainingTime) 220 try { 221 if (uiAutomatorCondition(uiAutomatorTimeout)) { 222 return 223 } else { 224 Log.d(TAG, "Failed condition for $message, will retry if within timeout") 225 } 226 } catch (e: StaleObjectException) { 227 Log.d(TAG, "StaleObjectException for $message, will retry if within timeout", e) 228 } 229 } 230 231 throw TimeoutException("Timed out waiting for $message") 232 } 233 stepsRecordFromTestAppnull234 fun stepsRecordFromTestApp(): StepsRecord { 235 return stepsRecord(TEST_APP_PACKAGE_NAME, /* stepCount= */ 10) 236 } 237 stepsRecordFromTestAppnull238 fun stepsRecordFromTestApp(stepCount: Long): StepsRecord { 239 return stepsRecord(TEST_APP_PACKAGE_NAME, stepCount) 240 } 241 stepsRecordFromTestAppnull242 fun stepsRecordFromTestApp(startTime: Instant): StepsRecord { 243 return stepsRecord( 244 TEST_APP_PACKAGE_NAME, /* stepCount= */ 10, startTime, startTime.plusSeconds(100)) 245 } 246 stepsRecordFromTestAppnull247 fun stepsRecordFromTestApp(stepCount: Long, startTime: Instant): StepsRecord { 248 return stepsRecord(TEST_APP_PACKAGE_NAME, stepCount, startTime, startTime.plusSeconds(100)) 249 } 250 stepsRecordFromTestApp2null251 fun stepsRecordFromTestApp2(): StepsRecord { 252 return stepsRecord(TEST_APP_2_PACKAGE_NAME, /* stepCount= */ 10) 253 } 254 distanceRecordFromTestAppnull255 fun distanceRecordFromTestApp(): DistanceRecord { 256 return distanceRecord(TEST_APP_PACKAGE_NAME) 257 } 258 distanceRecordFromTestAppnull259 fun distanceRecordFromTestApp(startTime: Instant): DistanceRecord { 260 return distanceRecord(TEST_APP_PACKAGE_NAME, startTime, startTime.plusSeconds(100)) 261 } 262 distanceRecordFromTestApp2null263 fun distanceRecordFromTestApp2(): DistanceRecord { 264 return distanceRecord(TEST_APP_2_PACKAGE_NAME) 265 } 266 stepsRecordnull267 private fun stepsRecord(packageName: String, stepCount: Long): StepsRecord { 268 return stepsRecord(packageName, stepCount, Instant.now().minusMillis(1000), Instant.now()) 269 } 270 stepsRecordnull271 private fun stepsRecord( 272 packageName: String, 273 stepCount: Long, 274 startTime: Instant, 275 endTime: Instant 276 ): StepsRecord { 277 val dataOrigin: DataOrigin = DataOrigin.Builder().setPackageName(packageName).build() 278 val testMetadataBuilder: Metadata.Builder = Metadata.Builder() 279 testMetadataBuilder.setDevice(TEST_DEVICE).setDataOrigin(dataOrigin) 280 testMetadataBuilder.setClientRecordId("SR" + Math.random()) 281 return StepsRecord.Builder(testMetadataBuilder.build(), startTime, endTime, stepCount) 282 .build() 283 } 284 distanceRecordnull285 private fun distanceRecord( 286 packageName: String, 287 startTime: Instant, 288 endTime: Instant 289 ): DistanceRecord { 290 val dataOrigin: DataOrigin = DataOrigin.Builder().setPackageName(packageName).build() 291 val testMetadataBuilder: Metadata.Builder = Metadata.Builder() 292 testMetadataBuilder.setDevice(TEST_DEVICE).setDataOrigin(dataOrigin) 293 testMetadataBuilder.setClientRecordId("SR" + Math.random()) 294 return DistanceRecord.Builder( 295 testMetadataBuilder.build(), startTime, endTime, Length.fromMeters(500.0)) 296 .build() 297 } 298 distanceRecordnull299 private fun distanceRecord(packageName: String): DistanceRecord { 300 val dataOrigin: DataOrigin = DataOrigin.Builder().setPackageName(packageName).build() 301 val testMetadataBuilder: Metadata.Builder = Metadata.Builder() 302 testMetadataBuilder.setDevice(TEST_DEVICE).setDataOrigin(dataOrigin) 303 testMetadataBuilder.setClientRecordId("SR" + Math.random()) 304 return DistanceRecord.Builder( 305 testMetadataBuilder.build(), 306 Instant.now().minusMillis(1000), 307 Instant.now(), 308 Length.fromMeters(500.0)) 309 .build() 310 } 311 grantPermissionViaPackageManagernull312 fun grantPermissionViaPackageManager(context: Context, packageName: String, permName: String) { 313 val pm = context.packageManager 314 if (pm.checkPermission(permName, packageName) == PERMISSION_GRANTED) { 315 return 316 } 317 runWithShellPermissionIdentity( 318 { pm.grantRuntimePermission(packageName, permName, context.user) }, 319 Manifest.permission.GRANT_RUNTIME_PERMISSIONS) 320 } 321 revokePermissionViaPackageManagernull322 fun revokePermissionViaPackageManager(context: Context, packageName: String, permName: String) { 323 val pm = context.packageManager 324 325 if (pm.checkPermission(permName, packageName) == PERMISSION_DENIED) { 326 runWithShellPermissionIdentity( 327 { 328 pm.updatePermissionFlags( 329 permName, 330 packageName, 331 MASK_PERMISSION_FLAGS, 332 PackageManager.FLAG_PERMISSION_USER_SET, 333 context.user) 334 }, 335 REVOKE_RUNTIME_PERMISSIONS) 336 return 337 } 338 runWithShellPermissionIdentity( 339 { pm.revokeRuntimePermission(packageName, permName, context.user, /* reason= */ "") }, 340 REVOKE_RUNTIME_PERMISSIONS) 341 } 342 setFontnull343 fun setFont(device: UiDevice) { 344 with(device) { 345 executeShellCommand( 346 "shell settings put system font_scale 0.85") 347 } 348 } 349 } 350