• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2023 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 
17 package com.android.safetycenter.testing
18 
19 import android.content.Context
20 import android.os.Build.VERSION_CODES.TIRAMISU
21 import android.os.SystemClock
22 import android.safetycenter.SafetySourceData
23 import android.safetycenter.SafetySourceIssue
24 import android.safetycenter.config.SafetySourcesGroup
25 import android.util.Log
26 import androidx.annotation.RequiresApi
27 import androidx.test.uiautomator.By
28 import androidx.test.uiautomator.BySelector
29 import androidx.test.uiautomator.StaleObjectException
30 import androidx.test.uiautomator.UiDevice
31 import androidx.test.uiautomator.UiObject2
32 import androidx.test.uiautomator.Until
33 import com.android.compatibility.common.util.SystemUtil.runShellCommand
34 import com.android.compatibility.common.util.UiAutomatorUtils2.getUiDevice
35 import com.android.compatibility.common.util.UiAutomatorUtils2.waitFindObject
36 import com.android.compatibility.common.util.UiDumpUtils
37 import java.time.Duration
38 import java.util.concurrent.TimeoutException
39 import java.util.regex.Pattern
40 
41 /** A class that helps with UI testing. */
42 object UiTestHelper {
43 
44     /** The label of the rescan button. */
45     const val RESCAN_BUTTON_LABEL = "Scan device"
46     /** The title of collapsible card that controls the visibility of additional issue cards. */
47     const val MORE_ISSUES_LABEL = "More alerts"
48 
49     private const val DISMISS_ISSUE_LABEL = "Dismiss"
50     private const val TAG = "SafetyCenterUiTestHelper"
51 
52     private val WAIT_TIMEOUT = Duration.ofSeconds(20)
53 
54     /**
55      * Waits for the given [selector] to be displayed, and optionally perform a given
56      * [uiObjectAction] on it.
57      */
<lambda>null58     fun waitDisplayed(selector: BySelector, uiObjectAction: (UiObject2) -> Unit = {}) {
59         val whenToTimeout = currentElapsedRealtime() + WAIT_TIMEOUT
60         var remaining = WAIT_TIMEOUT
61         while (remaining > Duration.ZERO) {
62             getUiDevice().waitForIdle()
63             try {
64                 uiObjectAction(waitFindObject(selector, remaining.toMillis()))
65                 return
66             } catch (e: StaleObjectException) {
67                 Log.w(TAG, "Found stale UI object, retrying", e)
68                 remaining = whenToTimeout - currentElapsedRealtime()
69             }
70         }
71         throw UiDumpUtils.wrapWithUiDump(
72             TimeoutException("Timed out waiting for $selector to be displayed after $WAIT_TIMEOUT")
73         )
74     }
75 
76     /** Waits for all the given [textToFind] to be displayed. */
waitAllTextDisplayednull77     fun waitAllTextDisplayed(vararg textToFind: CharSequence?) {
78         for (text in textToFind) {
79             if (text != null) waitDisplayed(By.text(text.toString()))
80         }
81     }
82 
83     /**
84      * Waits for a button with the given [label] to be displayed and performs the given
85      * [uiObjectAction] on it.
86      */
<lambda>null87     fun waitButtonDisplayed(label: CharSequence, uiObjectAction: (UiObject2) -> Unit = {}) =
88         waitDisplayed(buttonSelector(label), uiObjectAction)
89 
90     /** Waits for the given [selector] not to be displayed. */
waitNotDisplayednull91     fun waitNotDisplayed(selector: BySelector) {
92         // TODO(b/294038848): Add scrolling to make sure it is properly gone.
93         val gone = getUiDevice().wait(Until.gone(selector), WAIT_TIMEOUT.toMillis())
94         if (gone) {
95             return
96         }
97         throw UiDumpUtils.wrapWithUiDump(
98             TimeoutException(
99                 "Timed out waiting for $selector not to be displayed after $WAIT_TIMEOUT"
100             )
101         )
102     }
103 
104     /** Waits for all the given [textToFind] not to be displayed. */
waitAllTextNotDisplayednull105     fun waitAllTextNotDisplayed(vararg textToFind: CharSequence?) {
106         waitNotDisplayed(By.text(anyOf(*textToFind)))
107     }
108 
109     /** Waits for a button with the given [label] not to be displayed. */
waitButtonNotDisplayednull110     fun waitButtonNotDisplayed(label: CharSequence) {
111         waitNotDisplayed(buttonSelector(label))
112     }
113 
114     /**
115      * Waits for most of the [SafetySourceData] information to be displayed.
116      *
117      * This includes its UI entry and its issues.
118      */
119     @RequiresApi(TIRAMISU)
waitSourceDataDisplayednull120     fun waitSourceDataDisplayed(sourceData: SafetySourceData) {
121         for (sourceIssue in sourceData.issues) {
122             waitSourceIssueDisplayed(sourceIssue)
123         }
124 
125         waitAllTextDisplayed(sourceData.status?.title, sourceData.status?.summary)
126     }
127 
128     /** Waits for most of the [SafetySourceIssue] information to be displayed. */
129     @RequiresApi(TIRAMISU)
waitSourceIssueDisplayednull130     fun waitSourceIssueDisplayed(sourceIssue: SafetySourceIssue) {
131         waitAllTextDisplayed(sourceIssue.title, sourceIssue.subtitle, sourceIssue.summary)
132 
133         for (action in sourceIssue.actions) {
134             waitButtonDisplayed(action.label)
135         }
136     }
137 
138     /** Waits for most of the [SafetySourceIssue] information not to be displayed. */
139     @RequiresApi(TIRAMISU)
waitSourceIssueNotDisplayednull140     fun waitSourceIssueNotDisplayed(sourceIssue: SafetySourceIssue) {
141         waitAllTextNotDisplayed(sourceIssue.title)
142     }
143 
144     /**
145      * Waits for only one [SafetySourceIssue] to be displayed together with [MORE_ISSUES_LABEL]
146      * card, and for all other [SafetySourceIssue]s not to be diplayed.
147      */
waitCollapsedIssuesDisplayednull148     fun waitCollapsedIssuesDisplayed(vararg sourceIssues: SafetySourceIssue) {
149         waitSourceIssueDisplayed(sourceIssues.first())
150         waitAllTextDisplayed(MORE_ISSUES_LABEL)
151         waitAllTextNotDisplayed(*sourceIssues.drop(1).map { it.title }.toTypedArray())
152     }
153 
154     /** Waits for all the [SafetySourceIssue] to be displayed with the [MORE_ISSUES_LABEL] card. */
waitExpandedIssuesDisplayednull155     fun waitExpandedIssuesDisplayed(vararg sourceIssues: SafetySourceIssue) {
156         // to make landscape checks less flaky it is important to match their order with visuals
157         waitSourceIssueDisplayed(sourceIssues.first())
158         waitAllTextDisplayed(MORE_ISSUES_LABEL)
159         sourceIssues.asSequence().drop(1).forEach { waitSourceIssueDisplayed(it) }
160     }
161 
162     /** Waits for the specified screen title to be displayed. */
waitPageTitleDisplayednull163     fun waitPageTitleDisplayed(title: String) {
164         // CollapsingToolbar title can't be found by text, so using description instead.
165         waitDisplayed(By.desc(title))
166     }
167 
168     /** Waits for the specified screen title not to be displayed. */
waitPageTitleNotDisplayednull169     fun waitPageTitleNotDisplayed(title: String) {
170         // CollapsingToolbar title can't be found by text, so using description instead.
171         waitNotDisplayed(By.desc(title))
172     }
173 
174     /** Waits for the group title and summary to be displayed on the homepage */
waitGroupShownOnHomepagenull175     fun waitGroupShownOnHomepage(context: Context, group: SafetySourcesGroup) {
176         waitAllTextDisplayed(
177             context.getString(group.titleResId),
178             context.getString(group.summaryResId),
179         )
180     }
181 
182     /** Dismisses the issue card by clicking the dismiss button. */
clickDismissIssueCardnull183     fun clickDismissIssueCard() {
184         waitDisplayed(By.desc(DISMISS_ISSUE_LABEL)) { it.click() }
185     }
186 
187     /** Confirms the dismiss action by clicking on the dialog that pops up. */
clickConfirmDismissalnull188     fun clickConfirmDismissal() {
189         waitButtonDisplayed(DISMISS_ISSUE_LABEL) { it.click() }
190     }
191 
192     /** Clicks the brand chip button on a subpage in Safety Center. */
clickSubpageBrandChipnull193     fun clickSubpageBrandChip() {
194         waitButtonDisplayed("Security & privacy") { it.click() }
195     }
196 
197     /** Opens the subpage by clicking on the group title. */
clickOpenSubpagenull198     fun clickOpenSubpage(context: Context, group: SafetySourcesGroup) {
199         waitDisplayed(By.text(context.getString(group.titleResId))) { it.click() }
200         getUiDevice().waitForIdle()
201     }
202 
203     /** Clicks the more issues card button to show or hide additional issues. */
clickMoreIssuesCardnull204     fun clickMoreIssuesCard() {
205         waitDisplayed(By.text(MORE_ISSUES_LABEL)) { it.click() }
206     }
207 
208     /** Enables or disables animations based on [enabled]. */
setAnimationsEnablednull209     fun setAnimationsEnabled(enabled: Boolean) {
210         val scale =
211             if (enabled) {
212                 "1"
213             } else {
214                 "0"
215             }
216         runShellCommand("settings put global window_animation_scale $scale")
217         runShellCommand("settings put global transition_animation_scale $scale")
218         runShellCommand("settings put global animator_duration_scale $scale")
219     }
220 
rotatenull221     fun UiDevice.rotate() {
222         unfreezeRotation()
223         if (isNaturalOrientation) {
224             setOrientationLeft()
225         } else {
226             setOrientationNatural()
227         }
228         freezeRotation()
229         waitForIdle()
230     }
231 
resetRotationnull232     fun UiDevice.resetRotation() {
233         if (!isNaturalOrientation) {
234             unfreezeRotation()
235             setOrientationNatural()
236             freezeRotation()
237             waitForIdle()
238         }
239     }
240 
buttonSelectornull241     private fun buttonSelector(label: CharSequence): BySelector {
242         return By.clickable(true).text(anyOf(label, label.toString().uppercase()))
243     }
244 
anyOfnull245     private fun anyOf(vararg anyTextToFind: CharSequence?): Pattern {
246         val regex =
247             anyTextToFind.filterNotNull().joinToString(separator = "|") {
248                 Pattern.quote(it.toString())
249             }
250         return Pattern.compile(regex)
251     }
252 
currentElapsedRealtimenull253     private fun currentElapsedRealtime(): Duration =
254         Duration.ofMillis(SystemClock.elapsedRealtime())
255 }
256