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