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     }
201 
202     /** Clicks the more issues card button to show or hide additional issues. */
clickMoreIssuesCardnull203     fun clickMoreIssuesCard() {
204         waitDisplayed(By.text(MORE_ISSUES_LABEL)) { it.click() }
205     }
206 
207     /** Enables or disables animations based on [enabled]. */
setAnimationsEnablednull208     fun setAnimationsEnabled(enabled: Boolean) {
209         val scale =
210             if (enabled) {
211                 "1"
212             } else {
213                 "0"
214             }
215         runShellCommand("settings put global window_animation_scale $scale")
216         runShellCommand("settings put global transition_animation_scale $scale")
217         runShellCommand("settings put global animator_duration_scale $scale")
218     }
219 
rotatenull220     fun UiDevice.rotate() {
221         unfreezeRotation()
222         if (isNaturalOrientation) {
223             setOrientationLeft()
224         } else {
225             setOrientationNatural()
226         }
227         freezeRotation()
228         waitForIdle()
229     }
230 
resetRotationnull231     fun UiDevice.resetRotation() {
232         if (!isNaturalOrientation) {
233             unfreezeRotation()
234             setOrientationNatural()
235             freezeRotation()
236             waitForIdle()
237         }
238     }
239 
buttonSelectornull240     private fun buttonSelector(label: CharSequence): BySelector {
241         return By.clickable(true).text(anyOf(label, label.toString().uppercase()))
242     }
243 
anyOfnull244     private fun anyOf(vararg anyTextToFind: CharSequence?): Pattern {
245         val regex =
246             anyTextToFind.filterNotNull().joinToString(separator = "|") {
247                 Pattern.quote(it.toString())
248             }
249         return Pattern.compile(regex)
250     }
251 
currentElapsedRealtimenull252     private fun currentElapsedRealtime(): Duration =
253         Duration.ofMillis(SystemClock.elapsedRealtime())
254 }
255