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