• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2016 Google Inc.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#     http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14"""JSON RPC interface to Mobly Snippet Lib."""
15
16import re
17import time
18
19from mobly import utils
20from mobly.controllers.android_device_lib import adb
21from mobly.controllers.android_device_lib import errors
22from mobly.controllers.android_device_lib import jsonrpc_client_base
23
24_INSTRUMENTATION_RUNNER_PACKAGE = (
25    'com.google.android.mobly.snippet.SnippetRunner')
26
27# Major version of the launch and communication protocol being used by this
28# client.
29# Incrementing this means that compatibility with clients using the older
30# version is broken. Avoid breaking compatibility unless there is no other
31# choice.
32_PROTOCOL_MAJOR_VERSION = 1
33
34# Minor version of the launch and communication protocol.
35# Increment this when new features are added to the launch and communication
36# protocol that are backwards compatible with the old protocol and don't break
37# existing clients.
38_PROTOCOL_MINOR_VERSION = 0
39
40_LAUNCH_CMD = (
41    '{shell_cmd} am instrument {user} -w -e action start {snippet_package}/' +
42    _INSTRUMENTATION_RUNNER_PACKAGE)
43
44_STOP_CMD = ('am instrument {user} -w -e action stop {snippet_package}/' +
45             _INSTRUMENTATION_RUNNER_PACKAGE)
46
47# Test that uses UiAutomation requires the shell session to be maintained while
48# test is in progress. However, this requirement does not hold for the test that
49# deals with device USB disconnection (Once device disconnects, the shell
50# session that started the instrument ends, and UiAutomation fails with error:
51# "UiAutomation not connected"). To keep the shell session and redirect
52# stdin/stdout/stderr, use "setsid" or "nohup" while launching the
53# instrumentation test. Because these commands may not be available in every
54# android system, try to use them only if exists.
55_SETSID_COMMAND = 'setsid'
56
57_NOHUP_COMMAND = 'nohup'
58
59
60class AppStartPreCheckError(jsonrpc_client_base.Error):
61  """Raised when pre checks for the snippet failed."""
62
63
64class ProtocolVersionError(jsonrpc_client_base.AppStartError):
65  """Raised when the protocol reported by the snippet is unknown."""
66
67
68class SnippetClient(jsonrpc_client_base.JsonRpcClientBase):
69  """A client for interacting with snippet APKs using Mobly Snippet Lib.
70
71  See superclass documentation for a list of public attributes.
72
73  For a description of the launch protocols, see the documentation in
74  mobly-snippet-lib, SnippetRunner.java.
75  """
76
77  def __init__(self, package, ad):
78    """Initializes a SnippetClient.
79
80    Args:
81      package: (str) The package name of the apk where the snippets are
82        defined.
83      ad: (AndroidDevice) the device object associated with this client.
84    """
85    super().__init__(app_name=package, ad=ad)
86    self.package = package
87    self._ad = ad
88    self._adb = ad.adb
89    self._proc = None
90    self._user_id = None
91
92  @property
93  def is_alive(self):
94    """Does the client have an active connection to the snippet server."""
95    return self._conn is not None
96
97  @property
98  def user_id(self):
99    """The user id to use for this snippet client.
100
101    This value is cached and, once set, does not change through the lifecycles
102    of this snippet client object. This caching also reduces the number of adb
103    calls needed.
104
105    Because all the operations of the snippet client should be done for a
106    partucular user.
107    """
108    if self._user_id is None:
109      self._user_id = self._adb.current_user_id
110    return self._user_id
111
112  def _get_user_command_string(self):
113    """Gets the appropriate command argument for specifying user IDs.
114
115    By default, `SnippetClient` operates within the current user.
116
117    We don't add the `--user {ID}` arg when Android's SDK is below 24,
118    where multi-user support is not well implemented.
119
120    Returns:
121      String, the command param section to be formatted into the adb
122      commands.
123    """
124    sdk_int = int(self._ad.build_info['build_version_sdk'])
125    if sdk_int < 24:
126      return ''
127    return f'--user {self.user_id}'
128
129  def start_app_and_connect(self):
130    """Starts snippet apk on the device and connects to it.
131
132    This wraps the main logic with safe handling
133
134    Raises:
135      AppStartPreCheckError, when pre-launch checks fail.
136    """
137    try:
138      self._start_app_and_connect()
139    except AppStartPreCheckError:
140      # Precheck errors don't need cleanup, directly raise.
141      raise
142    except Exception as e:
143      # Log the stacktrace of `e` as re-raising doesn't preserve trace.
144      self._ad.log.exception('Failed to start app and connect.')
145      # If errors happen, make sure we clean up before raising.
146      try:
147        self.stop_app()
148      except Exception:
149        self._ad.log.exception(
150            'Failed to stop app after failure to start and connect.')
151      # Explicitly raise the original error from starting app.
152      raise e
153
154  def _start_app_and_connect(self):
155    """Starts snippet apk on the device and connects to it.
156
157    After prechecks, this launches the snippet apk with an adb cmd in a
158    standing subprocess, checks the cmd response from the apk for protocol
159    version, then sets up the socket connection over adb port-forwarding.
160
161    Args:
162      ProtocolVersionError, if protocol info or port info cannot be
163        retrieved from the snippet apk.
164    """
165    self._check_app_installed()
166    self.disable_hidden_api_blacklist()
167
168    persists_shell_cmd = self._get_persist_command()
169    # Use info here so people can follow along with the snippet startup
170    # process. Starting snippets can be slow, especially if there are
171    # multiple, and this avoids the perception that the framework is hanging
172    # for a long time doing nothing.
173    self.log.info('Launching snippet apk %s with protocol %d.%d', self.package,
174                  _PROTOCOL_MAJOR_VERSION, _PROTOCOL_MINOR_VERSION)
175    cmd = _LAUNCH_CMD.format(shell_cmd=persists_shell_cmd,
176                             user=self._get_user_command_string(),
177                             snippet_package=self.package)
178    start_time = time.perf_counter()
179    self._proc = self._do_start_app(cmd)
180
181    # Check protocol version and get the device port
182    line = self._read_protocol_line()
183    match = re.match('^SNIPPET START, PROTOCOL ([0-9]+) ([0-9]+)$', line)
184    if not match or match.group(1) != '1':
185      raise ProtocolVersionError(self._ad, line)
186
187    line = self._read_protocol_line()
188    match = re.match('^SNIPPET SERVING, PORT ([0-9]+)$', line)
189    if not match:
190      raise ProtocolVersionError(self._ad, line)
191    self.device_port = int(match.group(1))
192
193    # Forward the device port to a new host port, and connect to that port
194    self.host_port = utils.get_available_host_port()
195    self._adb.forward(['tcp:%d' % self.host_port, 'tcp:%d' % self.device_port])
196    self.connect()
197
198    # Yaaay! We're done!
199    self.log.debug('Snippet %s started after %.1fs on host port %s',
200                   self.package,
201                   time.perf_counter() - start_time, self.host_port)
202
203  def restore_app_connection(self, port=None):
204    """Restores the app after device got reconnected.
205
206    Instead of creating new instance of the client:
207      - Uses the given port (or find a new available host_port if none is
208      given).
209      - Tries to connect to remote server with selected port.
210
211    Args:
212      port: If given, this is the host port from which to connect to remote
213        device port. If not provided, find a new available port as host
214        port.
215
216    Raises:
217      AppRestoreConnectionError: When the app was not able to be started.
218    """
219    self.host_port = port or utils.get_available_host_port()
220    self._adb.forward(['tcp:%d' % self.host_port, 'tcp:%d' % self.device_port])
221    try:
222      self.connect()
223    except Exception:
224      # Log the original error and raise AppRestoreConnectionError.
225      self.log.exception('Failed to re-connect to app.')
226      raise jsonrpc_client_base.AppRestoreConnectionError(
227          self._ad,
228          ('Failed to restore app connection for %s at host port %s, '
229           'device port %s') % (self.package, self.host_port, self.device_port))
230
231    # Because the previous connection was lost, update self._proc
232    self._proc = None
233    self._restore_event_client()
234
235  def stop_app(self):
236    # Kill the pending 'adb shell am instrument -w' process if there is one.
237    # Although killing the snippet apk would abort this process anyway, we
238    # want to call stop_standing_subprocess() to perform a health check,
239    # print the failure stack trace if there was any, and reap it from the
240    # process table.
241    self.log.debug('Stopping snippet apk %s', self.package)
242    # Close the socket connection.
243    self.disconnect()
244    if self._proc:
245      utils.stop_standing_subprocess(self._proc)
246      self._proc = None
247    out = self._adb.shell(
248        _STOP_CMD.format(snippet_package=self.package,
249                         user=self._get_user_command_string())).decode('utf-8')
250    if 'OK (0 tests)' not in out:
251      raise errors.DeviceError(
252          self._ad, 'Failed to stop existing apk. Unexpected output: %s' % out)
253
254  def _start_event_client(self):
255    """Overrides superclass."""
256    event_client = SnippetClient(package=self.package, ad=self._ad)
257    event_client.host_port = self.host_port
258    event_client.device_port = self.device_port
259    event_client.connect(self.uid, jsonrpc_client_base.JsonRpcCommand.CONTINUE)
260    return event_client
261
262  def _restore_event_client(self):
263    """Restores previously created event client."""
264    if not self._event_client:
265      self._event_client = self._start_event_client()
266      return
267    self._event_client.host_port = self.host_port
268    self._event_client.device_port = self.device_port
269    self._event_client.connect()
270
271  def _check_app_installed(self):
272    # Check that the Mobly Snippet app is installed for the current user.
273    out = self._adb.shell(f'pm list package --user {self.user_id}')
274    if not utils.grep('^package:%s$' % self.package, out):
275      raise AppStartPreCheckError(
276          self._ad, f'{self.package} is not installed for user {self.user_id}.')
277    # Check that the app is instrumented.
278    out = self._adb.shell('pm list instrumentation')
279    matched_out = utils.grep(
280        f'^instrumentation:{self.package}/{_INSTRUMENTATION_RUNNER_PACKAGE}',
281        out)
282    if not matched_out:
283      raise AppStartPreCheckError(
284          self._ad, f'{self.package} is installed, but it is not instrumented.')
285    match = re.search(r'^instrumentation:(.*)\/(.*) \(target=(.*)\)$',
286                      matched_out[0])
287    target_name = match.group(3)
288    # Check that the instrumentation target is installed if it's not the
289    # same as the snippet package.
290    if target_name != self.package:
291      out = self._adb.shell(f'pm list package --user {self.user_id}')
292      if not utils.grep('^package:%s$' % target_name, out):
293        raise AppStartPreCheckError(
294            self._ad,
295            f'Instrumentation target {target_name} is not installed for user '
296            f'{self.user_id}.')
297
298  def _do_start_app(self, launch_cmd):
299    adb_cmd = [adb.ADB]
300    if self._adb.serial:
301      adb_cmd += ['-s', self._adb.serial]
302    adb_cmd += ['shell', launch_cmd]
303    return utils.start_standing_subprocess(adb_cmd, shell=False)
304
305  def _read_protocol_line(self):
306    """Reads the next line of instrumentation output relevant to snippets.
307
308    This method will skip over lines that don't start with 'SNIPPET' or
309    'INSTRUMENTATION_RESULT'.
310
311    Returns:
312      (str) Next line of snippet-related instrumentation output, stripped.
313
314    Raises:
315      jsonrpc_client_base.AppStartError: If EOF is reached without any
316        protocol lines being read.
317    """
318    while True:
319      line = self._proc.stdout.readline().decode('utf-8')
320      if not line:
321        raise jsonrpc_client_base.AppStartError(
322            self._ad, 'Unexpected EOF waiting for app to start')
323      # readline() uses an empty string to mark EOF, and a single newline
324      # to mark regular empty lines in the output. Don't move the strip()
325      # call above the truthiness check, or this method will start
326      # considering any blank output line to be EOF.
327      line = line.strip()
328      if (line.startswith('INSTRUMENTATION_RESULT:') or
329          line.startswith('SNIPPET ')):
330        self.log.debug('Accepted line from instrumentation output: "%s"', line)
331        return line
332      self.log.debug('Discarded line from instrumentation output: "%s"', line)
333
334  def _get_persist_command(self):
335    """Check availability and return path of command if available."""
336    for command in [_SETSID_COMMAND, _NOHUP_COMMAND]:
337      try:
338        if command in self._adb.shell(['which', command]).decode('utf-8'):
339          return command
340      except adb.AdbError:
341        continue
342    self.log.warning(
343        'No %s and %s commands available to launch instrument '
344        'persistently, tests that depend on UiAutomator and '
345        'at the same time performs USB disconnection may fail', _SETSID_COMMAND,
346        _NOHUP_COMMAND)
347    return ''
348
349  def help(self, print_output=True):
350    """Calls the help RPC, which returns the list of RPC calls available.
351
352    This RPC should normally be used in an interactive console environment
353    where the output should be printed instead of returned. Otherwise,
354    newlines will be escaped, which will make the output difficult to read.
355
356    Args:
357      print_output: A bool for whether the output should be printed.
358
359    Returns:
360      A str containing the help output otherwise None if print_output
361        wasn't set.
362    """
363    help_text = self._rpc('help')
364    if print_output:
365      print(help_text)
366    else:
367      return help_text
368