1 /*
2  * Copyright 2025 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 androidx.test.uiautomator
18 
19 import android.app.Instrumentation
20 import android.app.UiAutomation
21 import android.content.Intent
22 import android.view.accessibility.AccessibilityNodeInfo
23 import android.view.accessibility.AccessibilityWindowInfo
24 import androidx.test.platform.app.InstrumentationRegistry
25 import androidx.test.uiautomator.internal.AppManager
26 import androidx.test.uiautomator.watcher.ScopedUiWatcher
27 import androidx.test.uiautomator.watcher.WatcherRegistration
28 
29 /**
30  * Main entry point for ui automator tests. It creates a [UiAutomatorTestScope] in which a test can
31  * be defined.
32  *
33  * Example:
34  * ```kotlin
35  * @Test
36  * fun myTest() = uiAutomator {
37  *
38  *     startActivity(MyActivity::class.java)
39  *
40  *     onView { id == "button" }.click()
41  *
42  *     onView { id == "nested_elements" }
43  *         .apply {
44  *             onView { text == "First Level" }
45  *             onView { text == "Second Level" }
46  *             onView { text == "Third Level" }
47  *         }
48  * }
49  * ```
50  *
51  * @param block A block containing the test to run within the [UiAutomatorTestScope].
52  */
uiAutomatornull53 public fun uiAutomator(block: UiAutomatorTestScope.() -> (Unit)) {
54     val scope = UiAutomatorTestScope()
55     block(scope)
56     scope.afterBlock()
57 }
58 
59 /** A UiAutomator scope that allows to easily access UiAutomator api and utils class. */
60 public class UiAutomatorTestScope
61 internal constructor(
62     public val instrumentation: Instrumentation = InstrumentationRegistry.getInstrumentation(),
63     public val uiAutomation: UiAutomation = instrumentation.uiAutomation,
64     public val uiDevice: UiDevice = UiDevice.getInstance(instrumentation),
65 ) {
66 
67     internal companion object {
68         internal const val TAG = "UiAutomatorTestScope"
69     }
70 
71     private val appManager = AppManager(context = instrumentation.targetContext)
72     private val watcherRegistrations = mutableSetOf<WatcherRegistration>()
73 
afterBlocknull74     internal fun afterBlock() {
75         watcherRegistrations.forEach { it.unregister() }
76     }
77 
78     /**
79      * Registers a watcher for this [androidx.test.uiautomator.UiAutomatorTestScope] to handle
80      * unexpected UI elements. Internally this method uses the existing [UiDevice.registerWatcher]
81      * api. When the given [androidx.test.uiautomator.watcher.ScopedUiWatcher.isVisible] condition
82      * is satisfied, then the given [block] is executed. scope. This method returns a handler with
83      * the [WatcherRegistration] to unregister it before the block is complete. Note that this api
84      * helps with unexpected ui elements, such as system dialogs, and that for expected dialogs the
85      * [onView] api should be used.
86      *
87      * Usage:
88      * ```kotlin
89      * @Test fun myTest() = uiAutomator {
90      *
91      *     // Registers a watcher for a permission dialog.
92      *     watchFor(PermissionDialog) { clickAllow() }
93      *
94      *     // Registers a watcher for a custom dialog and unregisters it.
95      *     val registration = watchFor(MyDialog) { clickSomething() }
96      *     // Do something...
97      *     registration.unregister()
98      * }
99      * ```
100      *
101      * @param watcher the dialog to watch for.
102      * @param block a block to handle.
103      * @return the dialog registration.
104      */
watchFornull105     public fun <T> watchFor(
106         watcher: ScopedUiWatcher<T>,
107         block: T.() -> (Unit)
108     ): WatcherRegistration {
109         val id = watcher.toString()
110 
111         uiDevice.registerWatcher(id) {
112             val visible = watcher.isVisible()
113             if (visible) block(watcher.scope())
114             visible
115         }
116 
117         val registration =
118             object : WatcherRegistration {
119                 override fun unregister() {
120                     uiDevice.removeWatcher(id)
121                     watcherRegistrations.remove(this)
122                 }
123             }
124 
125         watcherRegistrations.add(registration)
126         return registration
127     }
128 
129     /**
130      * Performs a DFS on the accessibility tree starting from the root node in the active window and
131      * returns the first node matching the given [block]. The node is returned as an [UiObject2]
132      * that allows interacting with it. Internally it works searching periodically every
133      * [pollIntervalMs].
134      *
135      * Example:
136      * ```kotlin
137      * onView { textAsString == "Search" }.click()
138      * ```
139      *
140      * @param timeoutMs a timeout to find the view that satisfies the given condition.
141      * @param pollIntervalMs an interval to wait before rechecking the accessibility tree for
142      *   updates.
143      * @param block a block that specifies a condition on the node to find.
144      * @return a [UiObject2] from a node that matches the given [block] condition.
145      */
146     @JvmOverloads
onViewnull147     public fun onView(
148         timeoutMs: Long = 10000,
149         pollIntervalMs: Long = 100,
150         block: AccessibilityNodeInfo.() -> (Boolean),
151     ): UiObject2 = uiDevice.onView(timeoutMs, pollIntervalMs, block)
152 
153     /**
154      * Performs a DFS on the accessibility tree starting from the root node in the active window and
155      * returns the first node matching the given [block]. The node is returned as an [UiObject2]
156      * that allows interacting with it. Internally it works searching periodically every
157      * [pollIntervalMs].
158      *
159      * Example:
160      * ```kotlin
161      * onView { textAsString == "Search" }.click()
162      * ```
163      *
164      * @param timeoutMs a timeout to find the view that satisfies the given condition.
165      * @param pollIntervalMs an interval to wait before rechecking the accessibility tree for
166      *   updates.
167      * @param block a block that specifies a condition on the node to find.
168      * @return a [UiObject2] from a node that matches the given [block] condition or null.
169      */
170     @JvmOverloads
171     public fun onViewOrNull(
172         timeoutMs: Long = 10000,
173         pollIntervalMs: Long = 100,
174         block: AccessibilityNodeInfo.() -> (Boolean),
175     ): UiObject2? = uiDevice.onViewOrNull(timeoutMs, pollIntervalMs, block)
176 
177     /**
178      * Performs a DFS on the accessibility tree starting from the root node in the active window and
179      * returns the first node matching the given [block]. The node is returned as an [UiObject2]
180      * that allows interacting with it. Internally it works searching periodically every
181      * [pollIntervalMs].
182      *
183      * Example:
184      * ```kotlin
185      * node.onViews { isClass(Button::class.java) }
186      * ```
187      *
188      * @param timeoutMs a timeout to find the view that satisfies the given condition.
189      * @param pollIntervalMs an interval to wait before rechecking the accessibility tree for
190      *   updates.
191      * @param block a block that specifies a condition on the node to find.
192      * @return a list of [UiObject2] from nodes that matches the given [block] condition.
193      */
194     @JvmOverloads
195     public fun onViews(
196         timeoutMs: Long = 10000,
197         pollIntervalMs: Long = 100,
198         block: AccessibilityNodeInfo.() -> (Boolean),
199     ): List<UiObject2> = uiDevice.onViews(timeoutMs, pollIntervalMs, block)
200 
201     /**
202      * Waits for an application to become visible. Note that internally it checks if an
203      * accessibility node with the given [appPackageName] exists in the accessibility tree.
204      *
205      * @param appPackageName the package name of the app to wait for. By default is the target app
206      *   package name.
207      * @param timeoutMs a timeout for the app to become visible.
208      * @return whether the app became visible in the given timeout.
209      */
210     @JvmOverloads
211     public fun waitForAppToBeVisible(
212         appPackageName: String = instrumentation.targetContext.packageName,
213         timeoutMs: Long = 10000L,
214     ): Boolean =
215         uiDevice.waitForAppToBeVisible(
216             appPackageName = appPackageName,
217             timeoutMs = timeoutMs,
218         )
219 
220     /**
221      * Types the given [text] string simulating key press through [Instrumentation.sendKeySync].
222      * This is similar to tapping the keys on a virtual keyboard and will trigger the same listeners
223      * in the target app, as opposed to [AccessibilityNodeInfo.setText] that programmaticaly sets
224      * the given text in the target node.
225      *
226      * @param text the text to type.
227      */
228     public fun type(text: String): Unit = uiDevice.type(text)
229 
230     /**
231      * Similar to [type] but presses the delete key for the given [count] times.
232      *
233      * @param count how many times the press delete key should be pressed.
234      */
235     public fun pressDelete(count: Int): Unit = uiDevice.pressDelete(count)
236 
237     /** Press the enter key. */
238     public fun pressEnter(): Boolean = uiDevice.pressEnter()
239 
240     /** Press the back key. */
241     public fun pressBack(): Boolean = uiDevice.pressBack()
242 
243     /** Press the home key. */
244     public fun pressHome(): Boolean = uiDevice.pressHome()
245 
246     /** Returns all the windows on all the displays. */
247     public fun windows(): List<AccessibilityWindowInfo> = uiDevice.windows()
248 
249     /** Returns all the window roots on all the displays. */
250     public fun windowRoots(): List<AccessibilityNodeInfo> = uiDevice.windowRoots
251 
252     /**
253      * Returns the active window. Note that calling this method after [startApp], [startActivity] or
254      * [startIntent] without waiting for the app to be visible, will return the active window at the
255      * time of starting the app, i.e. the launcher if starting from there.
256      */
257     public fun activeWindow(): AccessibilityWindowInfo = activeWindowRoot().window
258 
259     /**
260      * Returns the active window root node. Note that calling this method after [startApp],
261      * [startActivity] or [startIntent] without waiting for the app to be visible, will return the
262      * active window root at the time of starting the app, i.e. the root of the launcher if starting
263      * from there.
264      */
265     public fun activeWindowRoot(): AccessibilityNodeInfo = uiDevice.waitForRootInActiveWindow()
266 
267     /** Starts the instrumentation test target app using the target app package name. */
268     public fun startApp(): Unit = startApp(instrumentation.targetContext.packageName)
269 
270     /**
271      * Starts the app with the given [packageName].
272      *
273      * @param packageName the package name of the app to start
274      */
275     public fun startApp(packageName: String): Unit = appManager.startApp(packageName = packageName)
276 
277     /**
278      * Starts an activity with the given [packageName] and [activityName].
279      *
280      * @param packageName the app package name of the activity to start.
281      * @param activityName the name of the activity to start.
282      */
283     public fun startActivity(
284         packageName: String,
285         activityName: String,
286     ): Unit = appManager.startActivity(packageName = packageName, activityName = activityName)
287 
288     /**
289      * Starts an activity with the given class.
290      *
291      * @param clazz the class of the activity to start.
292      */
293     public fun startActivity(clazz: Class<*>): Unit = appManager.startActivity(clazz = clazz)
294 
295     /**
296      * Starts the given [intent].
297      *
298      * @param intent an intent to start
299      */
300     public fun startIntent(intent: Intent): Unit = appManager.startIntent(intent = intent)
301 
302     /** Clears the instrumentation test target app data. */
303     public fun clearAppData(): Unit = appManager.clearAppData()
304 }
305