summaryrefslogtreecommitdiff
path: root/systrace/catapult/devil/devil/android/app_ui.py
blob: 2b04e8b8001dea615880bf12ee1952719296073f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
# Copyright 2015 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""Provides functionality to interact with UI elements of an Android app."""

import collections
import re
from xml.etree import ElementTree as element_tree

from devil.android import decorators
from devil.android import device_temp_file
from devil.utils import geometry
from devil.utils import timeout_retry

_DEFAULT_SHORT_TIMEOUT = 10
_DEFAULT_SHORT_RETRIES = 3
_DEFAULT_LONG_TIMEOUT = 30
_DEFAULT_LONG_RETRIES = 0

# Parse rectangle bounds given as: '[left,top][right,bottom]'.
_RE_BOUNDS = re.compile(
    r'\[(?P<left>\d+),(?P<top>\d+)\]\[(?P<right>\d+),(?P<bottom>\d+)\]')


class _UiNode(object):

  def __init__(self, device, xml_node, package=None):
    """Object to interact with a UI node from an xml snapshot.

    Note: there is usually no need to call this constructor directly. Instead,
    use an AppUi object (below) to grab an xml screenshot from a device and
    find nodes in it.

    Args:
      device: A device_utils.DeviceUtils instance.
      xml_node: An ElementTree instance of the node to interact with.
      package: An optional package name for the app owning this node.
    """
    self._device = device
    self._xml_node = xml_node
    self._package = package

  def _GetAttribute(self, key):
    """Get the value of an attribute of this node."""
    return self._xml_node.attrib.get(key)

  @property
  def bounds(self):
    """Get a rectangle with the bounds of this UI node.

    Returns:
      A geometry.Rectangle instance.
    """
    d = _RE_BOUNDS.match(self._GetAttribute('bounds')).groupdict()
    return geometry.Rectangle.FromDict({k: int(v) for k, v in d.iteritems()})

  def Tap(self, point=None, dp_units=False):
    """Send a tap event to the UI node.

    Args:
      point: An optional geometry.Point instance indicating the location to
        tap, relative to the bounds of the UI node, i.e. (0, 0) taps the
        top-left corner. If ommited, the center of the node is tapped.
      dp_units: If True, indicates that the coordinates of the point are given
        in device-independent pixels; otherwise they are assumed to be "real"
        pixels. This option has no effect when the point is ommited.
    """
    if point is None:
      point = self.bounds.center
    else:
      if dp_units:
        point = (float(self._device.pixel_density) / 160) * point
      point += self.bounds.top_left

    x, y = (str(int(v)) for v in point)
    self._device.RunShellCommand(['input', 'tap', x, y], check_return=True)

  def Dump(self):
    """Get a brief summary of the child nodes that can be found on this node.

    Returns:
      A list of lines that can be logged or otherwise printed.
    """
    summary = collections.defaultdict(set)
    for node in self._xml_node.iter():
      package = node.get('package') or '(no package)'
      label = node.get('resource-id') or '(no id)'
      text = node.get('text')
      if text:
        label = '%s[%r]' % (label, text)
      summary[package].add(label)
    lines = []
    for package, labels in sorted(summary.iteritems()):
      lines.append('- %s:' % package)
      for label in sorted(labels):
        lines.append('  - %s' % label)
    return lines

  def __getitem__(self, key):
    """Retrieve a child of this node by its index.

    Args:
      key: An integer with the index of the child to retrieve.
    Returns:
      A UI node instance of the selected child.
    Raises:
      IndexError if the index is out of range.
    """
    return type(self)(self._device, self._xml_node[key], package=self._package)

  def _Find(self, **kwargs):
    """Find the first descendant node that matches a given criteria.

    Note: clients would usually call AppUi.GetUiNode or AppUi.WaitForUiNode
    instead.

    For example:

      app = app_ui.AppUi(device, package='org.my.app')
      app.GetUiNode(resource_id='some_element', text='hello')

    would retrieve the first matching node with both of the xml attributes:

      resource-id='org.my.app:id/some_element'
      text='hello'

    As the example shows, if given and needed, the value of the resource_id key
    is auto-completed with the package name specified in the AppUi constructor.

    Args:
      Arguments are specified as key-value pairs, where keys correnspond to
      attribute names in xml nodes (replacing any '-' with '_' to make them
      valid identifiers). At least one argument must be supplied, and arguments
      with a None value are ignored.
    Returns:
      A UI node instance of the first descendant node that matches ALL the
      given key-value criteria; or None if no such node is found.
    Raises:
      TypeError if no search arguments are provided.
    """
    matches_criteria = self._NodeMatcher(kwargs)
    for node in self._xml_node.iter():
      if matches_criteria(node):
        return type(self)(self._device, node, package=self._package)
    return None

  def _NodeMatcher(self, kwargs):
    # Auto-complete resource-id's using the package name if available.
    resource_id = kwargs.get('resource_id')
    if (resource_id is not None
        and self._package is not None
        and ':id/' not in resource_id):
      kwargs['resource_id'] = '%s:id/%s' % (self._package, resource_id)

    criteria = [(k.replace('_', '-'), v)
                for k, v in kwargs.iteritems()
                if v is not None]
    if not criteria:
      raise TypeError('At least one search criteria should be specified')
    return lambda node: all(node.get(k) == v for k, v in criteria)


class AppUi(object):
  # timeout and retry arguments appear unused, but are handled by decorator.
  # pylint: disable=unused-argument

  def __init__(self, device, package=None):
    """Object to interact with the UI of an Android app.

    Args:
      device: A device_utils.DeviceUtils instance.
      package: An optional package name for the app.
    """
    self._device = device
    self._package = package

  @property
  def package(self):
    return self._package

  @decorators.WithTimeoutAndRetriesDefaults(_DEFAULT_SHORT_TIMEOUT,
                                            _DEFAULT_SHORT_RETRIES)
  def _GetRootUiNode(self, timeout=None, retries=None):
    """Get a node pointing to the root of the UI nodes on screen.

    Note: This is currently implemented via adb calls to uiatomator and it
    is *slow*, ~2 secs per call. Do not rely on low-level implementation
    details that may change in the future.

    TODO(crbug.com/567217): Swap to a more efficient implementation.

    Args:
      timeout: A number of seconds to wait for the uiautomator dump.
      retries: Number of times to retry if the adb command fails.
    Returns:
      A UI node instance pointing to the root of the xml screenshot.
    """
    with device_temp_file.DeviceTempFile(self._device.adb) as dtemp:
      self._device.RunShellCommand(['uiautomator', 'dump', dtemp.name],
                                  check_return=True)
      xml_node = element_tree.fromstring(
          self._device.ReadFile(dtemp.name, force_pull=True))
    return _UiNode(self._device, xml_node, package=self._package)

  def ScreenDump(self):
    """Get a brief summary of the nodes that can be found on the screen.

    Returns:
      A list of lines that can be logged or otherwise printed.
    """
    return self._GetRootUiNode().Dump()

  def GetUiNode(self, **kwargs):
    """Get the first node found matching a specified criteria.

    Args:
      See _UiNode._Find.
    Returns:
      A UI node instance of the node if found, otherwise None.
    """
    # pylint: disable=protected-access
    return self._GetRootUiNode()._Find(**kwargs)

  @decorators.WithTimeoutAndRetriesDefaults(_DEFAULT_LONG_TIMEOUT,
                                            _DEFAULT_LONG_RETRIES)
  def WaitForUiNode(self, timeout=None, retries=None, **kwargs):
    """Wait for a node matching a given criteria to appear on the screen.

    Args:
      timeout: A number of seconds to wait for the matching node to appear.
      retries: Number of times to retry in case of adb command errors.
      For other args, to specify the search criteria, see _UiNode._Find.
    Returns:
      The UI node instance found.
    Raises:
      device_errors.CommandTimeoutError if the node is not found before the
      timeout.
    """
    def node_found():
      return self.GetUiNode(**kwargs)

    return timeout_retry.WaitFor(node_found)