# 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\d+),(?P\d+)\]\[(?P\d+),(?P\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)