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