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