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