blob: 4f7af1d7d5c5fb2278ca5fd3846a86c199882fb7 [file] [log] [blame]
# 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 import decorators
from import device_temp_file
from devil.utils import geometry
from devil.utils import timeout_retry
# Parse rectangle bounds given as: '[left,top][right,bottom]'.
_RE_BOUNDS = re.compile(
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.
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)
def bounds(self):
"""Get a rectangle with the bounds of this UI node.
A geometry.Rectangle instance.
d = _RE_BOUNDS.match(self._GetAttribute('bounds')).groupdict()
return geometry.Rectangle.FromDict({k: int(v) for k, v in d.items()})
def Tap(self, point=None, dp_units=False):
"""Send a tap event to the UI node.
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 =
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.
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)
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.
key: An integer with the index of the child to retrieve.
A UI node instance of the selected child.
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
For example:
app = app_ui.AppUi(device, package='')
app.GetUiNode(resource_id='some_element', text='hello')
would retrieve the first matching node with both of the xml attributes:
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.
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.
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.
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.items()
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.
device: A device_utils.DeviceUtils instance.
package: An optional package name for the app.
self._device = device
self._package = package
def package(self):
return self._package
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( Swap to a more efficient implementation.
timeout: A number of seconds to wait for the uiautomator dump.
retries: Number of times to retry if the adb command fails.
A UI node instance pointing to the root of the xml screenshot.
with device_temp_file.DeviceTempFile(self._device.adb) as dtemp:
output = self._device.RunShellCommand(
['uiautomator', 'dump',], single_line=True,
if output.startswith('ERROR:'):
raise RuntimeError(
'uiautomator dump command returned error: {}'.format(output))
xml_node = element_tree.fromstring(
self._device.ReadFile(, 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.
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.
See _UiNode._Find.
A UI node instance of the node if found, otherwise None.
# pylint: disable=protected-access
return self._GetRootUiNode()._Find(**kwargs)
def WaitForUiNode(self, timeout=None, retries=None, **kwargs):
"""Wait for a node matching a given criteria to appear on the screen.
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.
The UI node instance found.
device_errors.CommandTimeoutError if the node is not found before the
def node_found():
return self.GetUiNode(**kwargs)
return timeout_retry.WaitFor(node_found)