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