xref: /aosp_15_r20/external/chromium-trace/catapult/devil/devil/android/app_ui.py (revision 1fa4b3da657c0e9ad43c0220bacf9731820715a5)
1# Copyright 2015 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4"""Provides functionality to interact with UI elements of an Android app."""
5
6import collections
7import re
8from xml.etree import ElementTree as element_tree
9
10from devil.android import decorators
11from devil.android import device_temp_file
12from devil.utils import geometry
13from devil.utils import timeout_retry
14
15_DEFAULT_SHORT_TIMEOUT = 10
16_DEFAULT_SHORT_RETRIES = 3
17_DEFAULT_LONG_TIMEOUT = 30
18_DEFAULT_LONG_RETRIES = 0
19
20# Parse rectangle bounds given as: '[left,top][right,bottom]'.
21_RE_BOUNDS = re.compile(
22    r'\[(?P<left>\d+),(?P<top>\d+)\]\[(?P<right>\d+),(?P<bottom>\d+)\]')
23
24
25class _UiNode(object):
26  def __init__(self, device, xml_node, package=None):
27    """Object to interact with a UI node from an xml snapshot.
28
29    Note: there is usually no need to call this constructor directly. Instead,
30    use an AppUi object (below) to grab an xml screenshot from a device and
31    find nodes in it.
32
33    Args:
34      device: A device_utils.DeviceUtils instance.
35      xml_node: An ElementTree instance of the node to interact with.
36      package: An optional package name for the app owning this node.
37    """
38    self._device = device
39    self._xml_node = xml_node
40    self._package = package
41
42  def _GetAttribute(self, key):
43    """Get the value of an attribute of this node."""
44    return self._xml_node.attrib.get(key)
45
46  @property
47  def bounds(self):
48    """Get a rectangle with the bounds of this UI node.
49
50    Returns:
51      A geometry.Rectangle instance.
52    """
53    d = _RE_BOUNDS.match(self._GetAttribute('bounds')).groupdict()
54    return geometry.Rectangle.FromDict({k: int(v) for k, v in d.items()})
55
56  def Tap(self, point=None, dp_units=False):
57    """Send a tap event to the UI node.
58
59    Args:
60      point: An optional geometry.Point instance indicating the location to
61        tap, relative to the bounds of the UI node, i.e. (0, 0) taps the
62        top-left corner. If ommited, the center of the node is tapped.
63      dp_units: If True, indicates that the coordinates of the point are given
64        in device-independent pixels; otherwise they are assumed to be "real"
65        pixels. This option has no effect when the point is ommited.
66    """
67    if point is None:
68      point = self.bounds.center
69    else:
70      if dp_units:
71        point = (float(self._device.pixel_density) / 160) * point
72      point += self.bounds.top_left
73
74    x, y = (str(int(v)) for v in point)
75    self._device.RunShellCommand(['input', 'tap', x, y], check_return=True)
76
77  def Dump(self):
78    """Get a brief summary of the child nodes that can be found on this node.
79
80    Returns:
81      A list of lines that can be logged or otherwise printed.
82    """
83    summary = collections.defaultdict(set)
84    for node in self._xml_node.iter():
85      package = node.get('package') or '(no package)'
86      label = node.get('resource-id') or '(no id)'
87      text = node.get('text')
88      if text:
89        label = '%s[%r]' % (label, text)
90      summary[package].add(label)
91    lines = []
92    for package, labels in sorted(summary.iteritems()):
93      lines.append('- %s:' % package)
94      for label in sorted(labels):
95        lines.append('  - %s' % label)
96    return lines
97
98  def __getitem__(self, key):
99    """Retrieve a child of this node by its index.
100
101    Args:
102      key: An integer with the index of the child to retrieve.
103    Returns:
104      A UI node instance of the selected child.
105    Raises:
106      IndexError if the index is out of range.
107    """
108    return type(self)(self._device, self._xml_node[key], package=self._package)
109
110  def _Find(self, **kwargs):
111    """Find the first descendant node that matches a given criteria.
112
113    Note: clients would usually call AppUi.GetUiNode or AppUi.WaitForUiNode
114    instead.
115
116    For example:
117
118      app = app_ui.AppUi(device, package='org.my.app')
119      app.GetUiNode(resource_id='some_element', text='hello')
120
121    would retrieve the first matching node with both of the xml attributes:
122
123      resource-id='org.my.app:id/some_element'
124      text='hello'
125
126    As the example shows, if given and needed, the value of the resource_id key
127    is auto-completed with the package name specified in the AppUi constructor.
128
129    Args:
130      Arguments are specified as key-value pairs, where keys correnspond to
131      attribute names in xml nodes (replacing any '-' with '_' to make them
132      valid identifiers). At least one argument must be supplied, and arguments
133      with a None value are ignored.
134    Returns:
135      A UI node instance of the first descendant node that matches ALL the
136      given key-value criteria; or None if no such node is found.
137    Raises:
138      TypeError if no search arguments are provided.
139    """
140    matches_criteria = self._NodeMatcher(kwargs)
141    for node in self._xml_node.iter():
142      if matches_criteria(node):
143        return type(self)(self._device, node, package=self._package)
144    return None
145
146  def _NodeMatcher(self, kwargs):
147    # Auto-complete resource-id's using the package name if available.
148    resource_id = kwargs.get('resource_id')
149    if (resource_id is not None and self._package is not None
150        and ':id/' not in resource_id):
151      kwargs['resource_id'] = '%s:id/%s' % (self._package, resource_id)
152
153    criteria = [(k.replace('_', '-'), v) for k, v in kwargs.items()
154                if v is not None]
155    if not criteria:
156      raise TypeError('At least one search criteria should be specified')
157    return lambda node: all(node.get(k) == v for k, v in criteria)
158
159
160class AppUi(object):
161  # timeout and retry arguments appear unused, but are handled by decorator.
162  # pylint: disable=unused-argument
163
164  def __init__(self, device, package=None):
165    """Object to interact with the UI of an Android app.
166
167    Args:
168      device: A device_utils.DeviceUtils instance.
169      package: An optional package name for the app.
170    """
171    self._device = device
172    self._package = package
173
174  @property
175  def package(self):
176    return self._package
177
178  @decorators.WithTimeoutAndRetriesDefaults(_DEFAULT_SHORT_TIMEOUT,
179                                            _DEFAULT_SHORT_RETRIES)
180  def _GetRootUiNode(self, timeout=None, retries=None):
181    """Get a node pointing to the root of the UI nodes on screen.
182
183    Note: This is currently implemented via adb calls to uiatomator and it
184    is *slow*, ~2 secs per call. Do not rely on low-level implementation
185    details that may change in the future.
186
187    TODO(crbug.com/567217): Swap to a more efficient implementation.
188
189    Args:
190      timeout: A number of seconds to wait for the uiautomator dump.
191      retries: Number of times to retry if the adb command fails.
192    Returns:
193      A UI node instance pointing to the root of the xml screenshot.
194    """
195    with device_temp_file.DeviceTempFile(self._device.adb) as dtemp:
196      output = self._device.RunShellCommand(
197          ['uiautomator', 'dump', dtemp.name], single_line=True,
198          check_return=True)
199      if output.startswith('ERROR:'):
200        raise RuntimeError(
201            'uiautomator dump command returned error: {}'.format(output))
202      xml_node = element_tree.fromstring(
203          self._device.ReadFile(dtemp.name, force_pull=True))
204    return _UiNode(self._device, xml_node, package=self._package)
205
206  def ScreenDump(self):
207    """Get a brief summary of the nodes that can be found on the screen.
208
209    Returns:
210      A list of lines that can be logged or otherwise printed.
211    """
212    return self._GetRootUiNode().Dump()
213
214  def GetUiNode(self, **kwargs):
215    """Get the first node found matching a specified criteria.
216
217    Args:
218      See _UiNode._Find.
219    Returns:
220      A UI node instance of the node if found, otherwise None.
221    """
222    # pylint: disable=protected-access
223    return self._GetRootUiNode()._Find(**kwargs)
224
225  @decorators.WithTimeoutAndRetriesDefaults(_DEFAULT_LONG_TIMEOUT,
226                                            _DEFAULT_LONG_RETRIES)
227  def WaitForUiNode(self, timeout=None, retries=None, **kwargs):
228    """Wait for a node matching a given criteria to appear on the screen.
229
230    Args:
231      timeout: A number of seconds to wait for the matching node to appear.
232      retries: Number of times to retry in case of adb command errors.
233      For other args, to specify the search criteria, see _UiNode._Find.
234    Returns:
235      The UI node instance found.
236    Raises:
237      device_errors.CommandTimeoutError if the node is not found before the
238      timeout.
239    """
240
241    def node_found():
242      return self.GetUiNode(**kwargs)
243
244    return timeout_retry.WaitFor(node_found)
245