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 @file:JvmName("AccessibilityNodeInfoExt")
17 
18 package androidx.test.uiautomator
19 
20 import android.graphics.Bitmap
21 import android.graphics.Rect
22 import android.os.Build
23 import android.view.Display
24 import android.view.accessibility.AccessibilityNodeInfo
25 import androidx.test.uiautomator.internal.displayManager
26 import androidx.test.uiautomator.internal.findViews
27 import androidx.test.uiautomator.internal.notNull
28 import androidx.test.uiautomator.internal.takeScreenshotBitmap
29 import androidx.test.uiautomator.internal.takeViewNodeTree
30 import androidx.test.uiautomator.internal.uiDevice
31 import androidx.test.uiautomator.internal.waitForStableInternal
32 
33 /**
34  * Returns the bounds of this node in screen coordinates. This method is a shortcut for
35  * [AccessibilityNodeInfo.getBoundsInScreen].
36  *
37  * @return a [Rect] with the bounds of this node within the screen limits.
38  */
<lambda>null39 public fun AccessibilityNodeInfo.boundsInScreen(): Rect = Rect().apply { getBoundsInScreen(this) }
40 
41 /**
42  * Returns all the immediate children of this node. To return all the descendants (including
43  * children of children , children of children of children and so on use[
44  * [AccessibilityNodeInfo.descendants].
45  *
46  * @param block an optional predicate to filter the node children
47  * @return the list of children of this node, with the given filter if specified.
48  */
49 @JvmOverloads
childrennull50 public fun AccessibilityNodeInfo.children(
51     block: (AccessibilityNodeInfo) -> Boolean = { true }
<lambda>null52 ): List<AccessibilityNodeInfo> = (0 until childCount).mapNotNull { getChild(it) }.filter(block)
53 
54 /**
55  * Performs a DFS on the accessibility tree starting from this node and returns all the descendants
56  * discovered recursively. Optionally a filter can be specified. Note that this differs from
57  * [AccessibilityNodeInfo.children()] because this will return also children of children, children
58  * of children of children and so on.
59  *
60  * @param filterBlock an optional predicate to filter the node children
61  * @return the list of all the descendants of this node, with the given filter if specified.
62  */
63 @JvmOverloads
descendantsnull64 public fun AccessibilityNodeInfo.descendants(
65     filterBlock: (AccessibilityNodeInfo) -> Boolean = { true }
66 ): List<AccessibilityNodeInfo> {
67     val collector = mutableListOf<AccessibilityNodeInfo>()
dfsnull68     fun dfs(node: AccessibilityNodeInfo) {
69         if (filterBlock(node)) collector.add(node)
70         node.children().forEach { dfs(it) }
71     }
72     dfs(this)
73     return collector
74 }
75 
76 /**
77  * Returns all the siblings of the this node.
78  *
79  * @param filterBlock an optional predicate to filter the node siblings
80  * @return the list of the siblings of this node, with the given filter if specified.
81  */
82 @JvmOverloads
siblingsnull83 public fun AccessibilityNodeInfo.siblings(
84     filterBlock: (AccessibilityNodeInfo) -> Boolean = { true }
85 ): List<AccessibilityNodeInfo> = parent.children().filter(filterBlock).minus(this)
86 
87 /**
88  * Takes a screenshot of the screen that contains this node and cuts only the area covered by it.
89  *
90  * @return a bitmap containing the image of this node.
91  */
takeScreenshotnull92 public fun AccessibilityNodeInfo.takeScreenshot(): Bitmap =
93     takeScreenshotBitmap(bounds = Rect().apply { getBoundsInScreen(this) })
94 
95 /**
96  * Waits for the node to become stable. A node is considered stable when it and its descendants have
97  * not changed over an interval of time. Optionally also the node image can be checked. Internally
98  * it works checking periodically that the internal properties of the node have not changed.
99  *
100  * @param stableTimeoutMs a timeout for the wait operation, to ensure not waiting forever for
101  *   stability.
102  * @param stableIntervalMs the interval during which the node should not be changing, in order to be
103  *   considered stable.
104  * @param stablePollIntervalMs specifies how often the ui should be checked for changes.
105  * @param requireStableScreenshot specifies if also the bitmap of the node should not change over
106  *   the specified [stableIntervalMs]. Note that this won't work with views that change constantly,
107  *   like a video player.
108  * @return a [androidx.test.uiautomator.StableResult] containing the latest acquired view hierarchy
109  *   and screenshot, and a flag indicating if the node was stable before timeout.
110  */
111 @JvmOverloads
waitForStablenull112 public fun AccessibilityNodeInfo.waitForStable(
113     stableTimeoutMs: Long = 3000,
114     stableIntervalMs: Long = 500,
115     stablePollIntervalMs: Long = 50,
116     requireStableScreenshot: Boolean = true,
117 ): StableResult {
118     val displayRect =
119         Rect().apply {
120             val displayId =
121                 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) window.displayId
122                 else Display.DEFAULT_DISPLAY
123             @Suppress("DEPRECATION") displayManager.getDisplay(displayId).getRectSize(this)
124         }
125 
126     return waitForStableInternal(
127         stableTimeoutMs = stableTimeoutMs,
128         stablePollIntervalMs = stablePollIntervalMs,
129         stableIntervalMs = stableIntervalMs,
130         bitmapProvider = { if (requireStableScreenshot) takeScreenshot() else null },
131         rootViewNodeProvider = { takeViewNodeTree(root = this, displayRect = displayRect) }
132     )
133 }
134 
135 /**
136  * Performs a DFS on the accessibility tree starting from this node and returns the first node
137  * matching the given [block]. The node is returned as an [UiObject2] that allows interacting with
138  * it. If the requested node doesn't exist, a [androidx.test.uiautomator.ViewNotFoundException] is
139  * thrown. Internally it works searching periodically every [pollIntervalMs].
140  *
141  * Example:
142  * ```kotlin
143  * node.onView { textAsString == "Search" }.click()
144  * ```
145  *
146  * @param timeoutMs a timeout to find the view that satisfies the given condition.
147  * @param pollIntervalMs an interval to wait before rechecking the accessibility tree for updates.
148  * @param block a block that specifies a condition on the node to find.
149  * @return a [UiObject2] from a node that matches the given [block] condition.
150  */
151 @JvmOverloads
onViewnull152 public fun AccessibilityNodeInfo.onView(
153     timeoutMs: Long = 10000,
154     pollIntervalMs: Long = 100,
155     block: AccessibilityNodeInfo.() -> (Boolean),
156 ): UiObject2 =
157     onViewOrNull(timeoutMs = timeoutMs, pollIntervalMs = pollIntervalMs, block = block)
158         .notNull(ViewNotFoundException())
159 
160 /**
161  * Performs a DFS on the accessibility tree starting from this node and returns the first node
162  * matching the given [block]. The node is returned as an [UiObject2] that allows interacting with
163  * it. If the requested node doesn't exist, null is returned. Internally it works searching
164  * periodically every [pollIntervalMs].
165  *
166  * Example:
167  * ```kotlin
168  * node.onView { textAsString == "Search" }.click()
169  * ```
170  *
171  * @param timeoutMs a timeout to find the view that satisfies the given condition.
172  * @param pollIntervalMs an interval to wait before rechecking the accessibility tree for updates.
173  * @param block a block that specifies a condition on the node to find.
174  * @return a [UiObject2] from a node that matches the given [block] condition or null.
175  */
176 @JvmOverloads
177 public fun AccessibilityNodeInfo.onViewOrNull(
178     timeoutMs: Long = 10000,
179     pollIntervalMs: Long = 100,
180     block: AccessibilityNodeInfo.() -> (Boolean),
181 ): UiObject2? =
182     findViews(
183             shouldStop = { it.size == 1 },
184             block = block,
185             timeoutMs = timeoutMs,
186             pollIntervalMs = pollIntervalMs,
<lambda>null187             rootNodeBlock = { listOf(this) }
188         )
189         .firstOrNull()
190         ?.toUiObject2()
191 
192 /**
193  * Performs a DFS on the accessibility tree starting from this node and returns all the nodes
194  * matching the given [block]. This method stops waiting as soon as a single node with the given
195  * condition is returned. The nodes returned are [UiObject2] that allow interacting with them.
196  * Internally it works searching periodically every [pollIntervalMs].
197  *
198  * Example:
199  * ```kotlin
200  * node.onViews { className == Button::class.java.name }
201  * ```
202  *
203  * If multiple nodes are expected but they appear at different times, it's recommended to call
204  * [androidx.test.uiautomator.waitForStable] before, to ensure any operation is complete.
205  *
206  * @param timeoutMs a timeout to find the view that satisfies the given condition.
207  * @param pollIntervalMs an interval to wait before rechecking the accessibility tree for updates.
208  * @param block a block that specifies a condition on the node to find.
209  * @return a list of [UiObject2] from nodes that matches the given [block] condition.
210  */
211 @JvmOverloads
onViewsnull212 public fun AccessibilityNodeInfo.onViews(
213     timeoutMs: Long = 10000,
214     pollIntervalMs: Long = 100,
215     block: AccessibilityNodeInfo.() -> (Boolean),
216 ): List<UiObject2> =
217     findViews(
218             timeoutMs = timeoutMs,
219             pollIntervalMs = pollIntervalMs,
220             shouldStop = { false },
221             block = block,
<lambda>null222             rootNodeBlock = { listOf(this) }
223         )
<lambda>null224         .mapNotNull { it.toUiObject2() }
225 
toUiObject2null226 internal fun AccessibilityNodeInfo.toUiObject2(): UiObject2? =
227     UiObject2.create(uiDevice, BySelector(), this)
228 
229 /**
230  * Returns this node's id without the full resource namespace, i.e. only the portion after the "/"
231  * character. If the view id is not specified, then it returns `null`.
232  */
233 public val AccessibilityNodeInfo.id: String?
234     get() = viewIdResourceName?.substringAfter("/")
235 
236 /**
237  * Returns this node's text as a string. This should always be preferred to
238  * [AccessibilityNodeInfo.text] that instead returns a [CharSequence], that might be either a
239  * [String] or a [android.text.SpannableString]. If a text is not specified, then it returns `null`.
240  *
241  * Usage:
242  * ```kotlin
243  * onView { textAsString == "Some text" }.click()
244  * ```
245  */
246 public val AccessibilityNodeInfo.textAsString: String?
247     get() = text?.toString()
248 
249 /**
250  * Returns whether this node class name is the same of the given class.
251  *
252  * Usage:
253  * ```kotlin
254  * onView { isClass(Button::class.java) }.click()
255  * ```
256  */
257 public fun AccessibilityNodeInfo.isClass(cls: Class<*>): Boolean = cls.name == this.className
258