• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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