• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2022 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"""Snippet Client V2 for Interacting with Snippet Server on Android Device."""
15
16import enum
17import json
18import re
19import socket
20
21from mobly import utils
22from mobly.controllers.android_device_lib import adb
23from mobly.controllers.android_device_lib import callback_handler_v2
24from mobly.controllers.android_device_lib import errors as android_device_lib_errors
25from mobly.snippet import client_base
26from mobly.snippet import errors
27
28# The package of the instrumentation runner used for mobly snippet
29_INSTRUMENTATION_RUNNER_PACKAGE = 'com.google.android.mobly.snippet.SnippetRunner'
30
31# The command template to start the snippet server
32_LAUNCH_CMD = (
33    '{shell_cmd} am instrument {user} -w -e action start {snippet_package}/'
34    f'{_INSTRUMENTATION_RUNNER_PACKAGE}')
35
36# The command template to stop the snippet server
37_STOP_CMD = ('am instrument {user} -w -e action stop {snippet_package}/'
38             f'{_INSTRUMENTATION_RUNNER_PACKAGE}')
39
40# Major version of the launch and communication protocol being used by this
41# client.
42# Incrementing this means that compatibility with clients using the older
43# version is broken. Avoid breaking compatibility unless there is no other
44# choice.
45_PROTOCOL_MAJOR_VERSION = 1
46
47# Minor version of the launch and communication protocol.
48# Increment this when new features are added to the launch and communication
49# protocol that are backwards compatible with the old protocol and don't break
50# existing clients.
51_PROTOCOL_MINOR_VERSION = 0
52
53# Test that uses UiAutomation requires the shell session to be maintained while
54# test is in progress. However, this requirement does not hold for the test that
55# deals with device disconnection (Once device disconnects, the shell session
56# that started the instrument ends, and UiAutomation fails with error:
57# "UiAutomation not connected"). To keep the shell session and redirect
58# stdin/stdout/stderr, use "setsid" or "nohup" while launching the
59# instrumentation test. Because these commands may not be available in every
60# Android system, try to use it only if at least one exists.
61_SETSID_COMMAND = 'setsid'
62
63_NOHUP_COMMAND = 'nohup'
64
65# UID of the 'unknown' JSON RPC session. Will cause creation of a new session
66# in the snippet server.
67UNKNOWN_UID = -1
68
69# Maximum time to wait for the socket to open on the device.
70_SOCKET_CONNECTION_TIMEOUT = 60
71
72# Maximum time to wait for a response message on the socket.
73_SOCKET_READ_TIMEOUT = 60 * 10
74
75# The default timeout for callback handlers returned by this client
76_CALLBACK_DEFAULT_TIMEOUT_SEC = 60 * 2
77
78
79class ConnectionHandshakeCommand(enum.Enum):
80  """Commands to send to the server when sending the handshake request.
81
82  After creating the socket connection, the client must send a handshake request
83  to the server. When receiving the handshake request, the server will prepare
84  to communicate with the client. According to the command in the request,
85  the server will create a new session or reuse the current session.
86
87  INIT: Initiates a new session and makes a connection with this session.
88  CONTINUE: Makes a connection with the current session.
89  """
90  INIT = 'initiate'
91  CONTINUE = 'continue'
92
93
94class SnippetClientV2(client_base.ClientBase):
95  """Snippet client V2 for interacting with snippet server on Android Device.
96
97  For a description of the launch protocols, see the documentation in
98  mobly-snippet-lib, SnippetRunner.java.
99
100  We only list the public attributes introduced in this class. See base class
101  documentation for other public attributes and communication protocols.
102
103  Attributes:
104    host_port: int, the host port used for communicating with the snippet
105      server.
106    device_port: int, the device port listened by the snippet server.
107    uid: int, the uid of the server session with which this client communicates.
108      Default is `UNKNOWN_UID` and it will be set to a positive number after
109      the connection to the server is made successfully.
110  """
111
112  def __init__(self, package, ad):
113    """Initializes the instance of Snippet Client V2.
114
115    Args:
116      package: str, see base class.
117      ad: AndroidDevice, the android device object associated with this client.
118    """
119    super().__init__(package=package, device=ad)
120    self.host_port = None
121    self.device_port = None
122    self.uid = UNKNOWN_UID
123    self._adb = ad.adb
124    self._user_id = None
125    self._proc = None
126    self._client = None  # keep it to prevent close errors on connect failure
127    self._conn = None
128    self._event_client = None
129
130  @property
131  def user_id(self):
132    """The user id to use for this snippet client.
133
134    All the operations of the snippet client should be used for a particular
135    user. For more details, see the Android documentation of testing
136    multiple users.
137
138    Thus this value is cached and, once set, does not change through the
139    lifecycles of this snippet client object. This caching also reduces the
140    number of adb calls needed.
141
142    Although for now self._user_id won't be modified once set, we use
143    `property` to avoid issuing adb commands in the constructor.
144
145    Returns:
146      An integer of the user id.
147    """
148    if self._user_id is None:
149      self._user_id = self._adb.current_user_id
150    return self._user_id
151
152  @property
153  def is_alive(self):
154    """Does the client have an active connection to the snippet server."""
155    return self._conn is not None
156
157  def before_starting_server(self):
158    """Performs the preparation steps before starting the remote server.
159
160    This function performs following preparation steps:
161    * Validate that the Mobly Snippet app is available on the device.
162    * Disable hidden api blocklist if necessary and possible.
163
164    Raises:
165      errors.ServerStartPreCheckError: if the server app is not installed
166        for the current user.
167    """
168    self._validate_snippet_app_on_device()
169    self._disable_hidden_api_blocklist()
170
171  def _validate_snippet_app_on_device(self):
172    """Validates the Mobly Snippet app is available on the device.
173
174    To run as an instrumentation test, the Mobly Snippet app must already be
175    installed and instrumented on the Android device.
176
177    Raises:
178      errors.ServerStartPreCheckError: if the server app is not installed
179        for the current user.
180    """
181    # Validate that the Mobly Snippet app is installed for the current user.
182    out = self._adb.shell(f'pm list package --user {self.user_id}')
183    if not utils.grep(f'^package:{self.package}$', out):
184      raise errors.ServerStartPreCheckError(
185          self._device,
186          f'{self.package} is not installed for user {self.user_id}.')
187
188    # Validate that the app is instrumented.
189    out = self._adb.shell('pm list instrumentation')
190    matched_out = utils.grep(
191        f'^instrumentation:{self.package}/{_INSTRUMENTATION_RUNNER_PACKAGE}',
192        out)
193    if not matched_out:
194      raise errors.ServerStartPreCheckError(
195          self._device,
196          f'{self.package} is installed, but it is not instrumented.')
197    match = re.search(r'^instrumentation:(.*)\/(.*) \(target=(.*)\)$',
198                      matched_out[0])
199    target_name = match.group(3)
200    # Validate that the instrumentation target is installed if it's not the
201    # same as the snippet package.
202    if target_name != self.package:
203      out = self._adb.shell(f'pm list package --user {self.user_id}')
204      if not utils.grep(f'^package:{target_name}$', out):
205        raise errors.ServerStartPreCheckError(
206            self._device,
207            f'Instrumentation target {target_name} is not installed for user '
208            f'{self.user_id}.')
209
210  def _disable_hidden_api_blocklist(self):
211    """If necessary and possible, disables hidden api blocklist."""
212    sdk_version = int(self._device.build_info['build_version_sdk'])
213    if self._device.is_rootable and sdk_version >= 28:
214      self._device.adb.shell(
215          'settings put global hidden_api_blacklist_exemptions "*"')
216
217  def start_server(self):
218    """Starts the server on the remote device.
219
220    This function starts the snippet server with adb command, checks the
221    protocol version of the server, parses device port from the server
222    output and sets it to self.device_port.
223
224    Raises:
225      errors.ServerStartProtocolError: if the protocol reported by the server
226        startup process is unknown.
227      errors.ServerStartError: if failed to start the server or process the
228        server output.
229    """
230    persists_shell_cmd = self._get_persisting_command()
231    self.log.debug('Snippet server for package %s is using protocol %d.%d',
232                   self.package, _PROTOCOL_MAJOR_VERSION,
233                   _PROTOCOL_MINOR_VERSION)
234    cmd = _LAUNCH_CMD.format(shell_cmd=persists_shell_cmd,
235                             user=self._get_user_command_string(),
236                             snippet_package=self.package)
237    self._proc = self._run_adb_cmd(cmd)
238
239    # Check protocol version and get the device port
240    line = self._read_protocol_line()
241    match = re.match('^SNIPPET START, PROTOCOL ([0-9]+) ([0-9]+)$', line)
242    if not match or int(match.group(1)) != _PROTOCOL_MAJOR_VERSION:
243      raise errors.ServerStartProtocolError(self._device, line)
244
245    line = self._read_protocol_line()
246    match = re.match('^SNIPPET SERVING, PORT ([0-9]+)$', line)
247    if not match:
248      raise errors.ServerStartProtocolError(self._device, line)
249    self.device_port = int(match.group(1))
250
251  def _run_adb_cmd(self, cmd):
252    """Starts a long-running adb subprocess and returns it immediately."""
253    adb_cmd = [adb.ADB]
254    if self._adb.serial:
255      adb_cmd += ['-s', self._adb.serial]
256    adb_cmd += ['shell', cmd]
257    return utils.start_standing_subprocess(adb_cmd, shell=False)
258
259  def _get_persisting_command(self):
260    """Returns the path of a persisting command if available."""
261    for command in [_SETSID_COMMAND, _NOHUP_COMMAND]:
262      try:
263        if command in self._adb.shell(['which', command]).decode('utf-8'):
264          return command
265      except adb.AdbError:
266        continue
267
268    self.log.warning(
269        'No %s and %s commands available to launch instrument '
270        'persistently, tests that depend on UiAutomator and '
271        'at the same time perform USB disconnections may fail.',
272        _SETSID_COMMAND, _NOHUP_COMMAND)
273    return ''
274
275  def _get_user_command_string(self):
276    """Gets the appropriate command argument for specifying device user ID.
277
278    By default, this client operates within the current user. We
279    don't add the `--user {ID}` argument when Android's SDK is below 24,
280    where multi-user support is not well implemented.
281
282    Returns:
283      A string of the command argument section to be formatted into
284      adb commands.
285    """
286    sdk_version = int(self._device.build_info['build_version_sdk'])
287    if sdk_version < 24:
288      return ''
289    return f'--user {self.user_id}'
290
291  def _read_protocol_line(self):
292    """Reads the next line of instrumentation output relevant to snippets.
293
294    This method will skip over lines that don't start with 'SNIPPET ' or
295    'INSTRUMENTATION_RESULT:'.
296
297    Returns:
298      A string for the next line of snippet-related instrumentation output,
299        stripped.
300
301    Raises:
302      errors.ServerStartError: If EOF is reached without any protocol lines
303        being read.
304    """
305    while True:
306      line = self._proc.stdout.readline().decode('utf-8')
307      if not line:
308        raise errors.ServerStartError(
309            self._device, 'Unexpected EOF when waiting for server to start.')
310
311      # readline() uses an empty string to mark EOF, and a single newline
312      # to mark regular empty lines in the output. Don't move the strip()
313      # call above the truthiness check, or this method will start
314      # considering any blank output line to be EOF.
315      line = line.strip()
316      if (line.startswith('INSTRUMENTATION_RESULT:') or
317          line.startswith('SNIPPET ')):
318        self.log.debug('Accepted line from instrumentation output: "%s"', line)
319        return line
320
321      self.log.debug('Discarded line from instrumentation output: "%s"', line)
322
323  def make_connection(self):
324    """Makes a connection to the snippet server on the remote device.
325
326    This function makes a persistent connection to the server. This connection
327    will be used for all the RPCs, and must be closed when deconstructing.
328
329    To connect to the Android device, it first forwards the device port to a
330    host port. Then, it creates a socket connection to the server on the device.
331    Finally, it sends a handshake request to the server, which requests the
332    server to prepare for the communication with the client.
333
334    This function uses self.host_port for communicating with the server. If
335    self.host_port is 0 or None, this function finds an available host port to
336    make the connection and set self.host_port to the found port.
337    """
338    self._forward_device_port()
339    self.create_socket_connection()
340    self.send_handshake_request()
341
342  def _forward_device_port(self):
343    """Forwards the device port to a host port."""
344    if not self.host_port:
345      self.host_port = utils.get_available_host_port()
346    self._adb.forward([f'tcp:{self.host_port}', f'tcp:{self.device_port}'])
347
348  def create_socket_connection(self):
349    """Creates a socket connection to the server.
350
351    After creating the connection successfully, it sets two attributes:
352    * `self._conn`: the created socket object, which will be used when it needs
353      to close the connection.
354    * `self._client`: the socket file, which will be used to send and receive
355      messages.
356
357    This function only creates a socket connection without sending any message
358    to the server.
359    """
360    try:
361      self.log.debug(
362          'Snippet client is creating socket connection to the snippet server '
363          'of %s through host port %d.', self.package, self.host_port)
364      self._conn = socket.create_connection(('localhost', self.host_port),
365                                            _SOCKET_CONNECTION_TIMEOUT)
366    except ConnectionRefusedError as err:
367      # Retry using '127.0.0.1' for IPv4 enabled machines that only resolve
368      # 'localhost' to '[::1]'.
369      self.log.debug('Failed to connect to localhost, trying 127.0.0.1: %s',
370                     str(err))
371      self._conn = socket.create_connection(('127.0.0.1', self.host_port),
372                                            _SOCKET_CONNECTION_TIMEOUT)
373
374    self._conn.settimeout(_SOCKET_READ_TIMEOUT)
375    self._client = self._conn.makefile(mode='brw')
376
377  def send_handshake_request(self,
378                             uid=UNKNOWN_UID,
379                             cmd=ConnectionHandshakeCommand.INIT):
380    """Sends a handshake request to the server to prepare for the communication.
381
382    Through the handshake response, this function checks whether the server
383    is ready for the communication. If ready, it sets `self.uid` to the
384    server session id. Otherwise, it sets `self.uid` to `UNKNOWN_UID`.
385
386    Args:
387      uid: int, the uid of the server session to continue. It will be ignored
388        if the `cmd` requires the server to create a new session.
389      cmd: ConnectionHandshakeCommand, the handshake command Enum for the
390        server, which requires the server to create a new session or use the
391        current session.
392
393    Raises:
394      errors.ProtocolError: something went wrong when sending the handshake
395        request.
396    """
397    request = json.dumps({'cmd': cmd.value, 'uid': uid})
398    self.log.debug('Sending handshake request %s.', request)
399    self._client_send(request)
400    response = self._client_receive()
401
402    if not response:
403      raise errors.ProtocolError(
404          self._device, errors.ProtocolError.NO_RESPONSE_FROM_HANDSHAKE)
405
406    response = self._decode_socket_response_bytes(response)
407
408    result = json.loads(response)
409    if result['status']:
410      self.uid = result['uid']
411    else:
412      self.uid = UNKNOWN_UID
413
414  def check_server_proc_running(self):
415    """See base class.
416
417    This client does nothing at this stage.
418    """
419
420  def send_rpc_request(self, request):
421    """Sends an RPC request to the server and receives a response.
422
423    Args:
424      request: str, the request to send the server.
425
426    Returns:
427      The string of the RPC response.
428
429    Raises:
430      errors.Error: if failed to send the request or receive a response.
431      errors.ProtocolError: if received an empty response from the server.
432      UnicodeError: if failed to decode the received response.
433    """
434    self._client_send(request)
435    response = self._client_receive()
436    if not response:
437      raise errors.ProtocolError(self._device,
438                                 errors.ProtocolError.NO_RESPONSE_FROM_SERVER)
439    return self._decode_socket_response_bytes(response)
440
441  def _client_send(self, message):
442    """Sends an RPC message through the connection.
443
444    Args:
445      message: str, the message to send.
446
447    Raises:
448      errors.Error: if a socket error occurred during the send.
449    """
450    try:
451      self._client.write(f'{message}\n'.encode('utf8'))
452      self._client.flush()
453    except socket.error as e:
454      raise errors.Error(
455          self._device,
456          f'Encountered socket error "{e}" sending RPC message "{message}"'
457      ) from e
458
459  def _client_receive(self):
460    """Receives the server's response of an RPC message.
461
462    Returns:
463      Raw bytes of the response.
464
465    Raises:
466      errors.Error: if a socket error occurred during the read.
467    """
468    try:
469      return self._client.readline()
470    except socket.error as e:
471      raise errors.Error(
472          self._device,
473          f'Encountered socket error "{e}" reading RPC response') from e
474
475  def _decode_socket_response_bytes(self, response):
476    """Returns a string decoded from the socket response bytes.
477
478    Args:
479      response: bytes, the response to be decoded.
480
481    Returns:
482      The string decoded from the given bytes.
483
484    Raises:
485      UnicodeError: if failed to decode the given bytes using encoding utf8.
486    """
487    try:
488      return str(response, encoding='utf8')
489    except UnicodeError:
490      self.log.error(
491          'Failed to decode socket response bytes using encoding '
492          'utf8: %s', response)
493      raise
494
495  def handle_callback(self, callback_id, ret_value, rpc_func_name):
496    """Creates the callback handler object.
497
498    If the client doesn't have an event client, it will start an event client
499    before creating a callback handler.
500
501    Args:
502      callback_id: see base class.
503      ret_value: see base class.
504      rpc_func_name: see base class.
505
506    Returns:
507      The callback handler object.
508    """
509    if self._event_client is None:
510      self._create_event_client()
511    return callback_handler_v2.CallbackHandlerV2(
512        callback_id=callback_id,
513        event_client=self._event_client,
514        ret_value=ret_value,
515        method_name=rpc_func_name,
516        device=self._device,
517        rpc_max_timeout_sec=_SOCKET_READ_TIMEOUT,
518        default_timeout_sec=_CALLBACK_DEFAULT_TIMEOUT_SEC)
519
520  def _create_event_client(self):
521    """Creates a separate client to the same session for propagating events.
522
523    As the server is already started by the snippet server on which this
524    function is called, the created event client connects to the same session
525    as the snippet server. It also reuses the same host port and device port.
526    """
527    self._event_client = SnippetClientV2(package=self.package, ad=self._device)
528    self._event_client.make_connection_with_forwarded_port(
529        self.host_port, self.device_port, self.uid,
530        ConnectionHandshakeCommand.CONTINUE)
531
532  def make_connection_with_forwarded_port(self,
533                                          host_port,
534                                          device_port,
535                                          uid=UNKNOWN_UID,
536                                          cmd=ConnectionHandshakeCommand.INIT):
537    """Makes a connection to the server with the given forwarded port.
538
539    This process assumes that a device port has already been forwarded to a
540    host port, and it only makes a connection to the snippet server based on
541    the forwarded port. This is typically used by clients that share the same
542    snippet server, e.g. the snippet client and its event client.
543
544    Args:
545      host_port: int, the host port which has already been forwarded.
546      device_port: int, the device port listened by the snippet server.
547      uid: int, the uid of the server session to continue. It will be ignored
548        if the `cmd` requires the server to create a new session.
549      cmd: ConnectionHandshakeCommand, the handshake command Enum for the
550        server, which requires the server to create a new session or use the
551        current session.
552    """
553    self.host_port = host_port
554    self.device_port = device_port
555    self._counter = self._id_counter()
556    self.create_socket_connection()
557    self.send_handshake_request(uid, cmd)
558
559  def stop(self):
560    """Releases all the resources acquired in `initialize`.
561
562    This function releases following resources:
563    * Close the socket connection.
564    * Stop forwarding the device port to host.
565    * Stop the standing server subprocess running on the host side.
566    * Stop the snippet server running on the device side.
567    * Stop the event client and set `self._event_client` to None.
568
569    Raises:
570      android_device_lib_errors.DeviceError: if the server exited with errors on
571        the device side.
572    """
573    self.log.debug('Stopping snippet package %s.', self.package)
574    self.close_connection()
575    self._stop_server()
576    self._destroy_event_client()
577    self.log.debug('Snippet package %s stopped.', self.package)
578
579  def close_connection(self):
580    """Closes the connection to the snippet server on the device.
581
582    This function closes the socket connection and stops forwarding the device
583    port to host.
584    """
585    try:
586      if self._conn:
587        self._conn.close()
588        self._conn = None
589    finally:
590      # Always clear the host port as part of the close step
591      self._stop_port_forwarding()
592
593  def _stop_port_forwarding(self):
594    """Stops the adb port forwarding used by this client."""
595    if self.host_port:
596      self._device.adb.forward(['--remove', f'tcp:{self.host_port}'])
597      self.host_port = None
598
599  def _stop_server(self):
600    """Releases all the resources acquired in `start_server`.
601
602    Raises:
603      android_device_lib_errors.DeviceError: if the server exited with errors on
604        the device side.
605    """
606    # Although killing the snippet server would abort this subprocess anyway, we
607    # want to call stop_standing_subprocess() to perform a health check,
608    # print the failure stack trace if there was any, and reap it from the
609    # process table. Note that it's much more important to ensure releasing all
610    # the allocated resources on the host side than on the remote device side.
611
612    # Stop the standing server subprocess running on the host side.
613    if self._proc:
614      utils.stop_standing_subprocess(self._proc)
615      self._proc = None
616
617    # Send the stop signal to the server running on the device side.
618    out = self._adb.shell(
619        _STOP_CMD.format(snippet_package=self.package,
620                         user=self._get_user_command_string())).decode('utf-8')
621
622    if 'OK (0 tests)' not in out:
623      raise android_device_lib_errors.DeviceError(
624          self._device,
625          f'Failed to stop existing apk. Unexpected output: {out}.')
626
627  def _destroy_event_client(self):
628    """Releases all the resources acquired in `_create_event_client`."""
629    if self._event_client:
630      # Without cleaning host_port of event_client first, the close_connection
631      # will try to stop the port forwarding, which should only be stopped by
632      # the corresponding snippet client.
633      self._event_client.host_port = None
634      self._event_client.device_port = None
635      self._event_client.close_connection()
636      self._event_client = None
637
638  def restore_server_connection(self, port=None):
639    """Restores the server after the device got reconnected.
640
641    Instead of creating a new instance of the client:
642      - Uses the given port (or find a new available host port if none is
643      given).
644      - Tries to connect to the remote server with the selected port.
645
646    Args:
647      port: int, if given, this is the host port from which to connect to the
648        remote device port. If not provided, find a new available port as host
649        port.
650
651    Raises:
652      errors.ServerRestoreConnectionError: when failed to restore the connection
653        to the snippet server.
654    """
655    try:
656      # If self.host_port is None, self._make_connection finds a new available
657      # port.
658      self.host_port = port
659      self._make_connection()
660    except Exception as e:
661      # Log the original error and raise ServerRestoreConnectionError.
662      self.log.error('Failed to re-connect to the server.')
663      raise errors.ServerRestoreConnectionError(
664          self._device,
665          (f'Failed to restore server connection for {self.package} at '
666           f'host port {self.host_port}, device port {self.device_port}.'
667          )) from e
668
669    # Because the previous connection was lost, update self._proc
670    self._proc = None
671    self._restore_event_client()
672
673  def _restore_event_client(self):
674    """Restores the previously created event client or creates a new one.
675
676    This function restores the connection of the previously created event
677    client, or creates a new client and makes a connection if it didn't
678    exist before.
679
680    The event client to restore reuses the same host port and device port
681    with the client on which function is called.
682    """
683    if self._event_client:
684      self._event_client.make_connection_with_forwarded_port(
685          self.host_port, self.device_port)
686
687  def help(self, print_output=True):
688    """Calls the help RPC, which returns the list of RPC calls available.
689
690    This RPC should normally be used in an interactive console environment
691    where the output should be printed instead of returned. Otherwise,
692    newlines will be escaped, which will make the output difficult to read.
693
694    Args:
695      print_output: bool, for whether the output should be printed.
696
697    Returns:
698      A string containing the help output otherwise None if `print_output`
699        wasn't set.
700    """
701    help_text = self._rpc('help')
702    if print_output:
703      print(help_text)
704    else:
705      return help_text
706