Home | History | Annotate | Download | only in android
      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 
      7 import collections
      8 import re
      9 from xml.etree import ElementTree as element_tree
     10 
     11 from devil.android import decorators
     12 from devil.android import device_temp_file
     13 from devil.utils import geometry
     14 from 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 
     26 class _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 
    164 class 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