| # Copyright 2016 Google Inc. |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| """JSON RPC interface to Mobly Snippet Lib.""" |
| |
| from __future__ import print_function |
| |
| import re |
| import time |
| |
| from mobly import utils |
| from mobly.controllers.android_device_lib import adb |
| from mobly.controllers.android_device_lib import errors |
| from mobly.controllers.android_device_lib import jsonrpc_client_base |
| |
| _INSTRUMENTATION_RUNNER_PACKAGE = ( |
| 'com.google.android.mobly.snippet.SnippetRunner') |
| |
| # Major version of the launch and communication protocol being used by this |
| # client. |
| # Incrementing this means that compatibility with clients using the older |
| # version is broken. Avoid breaking compatibility unless there is no other |
| # choice. |
| _PROTOCOL_MAJOR_VERSION = 1 |
| |
| # Minor version of the launch and communication protocol. |
| # Increment this when new features are added to the launch and communication |
| # protocol that are backwards compatible with the old protocol and don't break |
| # existing clients. |
| _PROTOCOL_MINOR_VERSION = 0 |
| |
| _LAUNCH_CMD = ( |
| '{shell_cmd} am instrument {user} -w -e action start {snippet_package}/' + |
| _INSTRUMENTATION_RUNNER_PACKAGE) |
| |
| _STOP_CMD = ('am instrument {user} -w -e action stop {snippet_package}/' + |
| _INSTRUMENTATION_RUNNER_PACKAGE) |
| |
| # Test that uses UiAutomation requires the shell session to be maintained while |
| # test is in progress. However, this requirement does not hold for the test that |
| # deals with device USB disconnection (Once device disconnects, the shell |
| # session that started the instrument ends, and UiAutomation fails with error: |
| # "UiAutomation not connected"). To keep the shell session and redirect |
| # stdin/stdout/stderr, use "setsid" or "nohup" while launching the |
| # instrumentation test. Because these commands may not be available in every |
| # android system, try to use them only if exists. |
| _SETSID_COMMAND = 'setsid' |
| |
| _NOHUP_COMMAND = 'nohup' |
| |
| |
| class AppStartPreCheckError(jsonrpc_client_base.Error): |
| """Raised when pre checks for the snippet failed.""" |
| |
| |
| class ProtocolVersionError(jsonrpc_client_base.AppStartError): |
| """Raised when the protocol reported by the snippet is unknown.""" |
| |
| |
| class SnippetClient(jsonrpc_client_base.JsonRpcClientBase): |
| """A client for interacting with snippet APKs using Mobly Snippet Lib. |
| |
| See superclass documentation for a list of public attributes. |
| |
| For a description of the launch protocols, see the documentation in |
| mobly-snippet-lib, SnippetRunner.java. |
| """ |
| |
| def __init__(self, package, ad): |
| """Initializes a SnippetClient. |
| |
| Args: |
| package: (str) The package name of the apk where the snippets are |
| defined. |
| ad: (AndroidDevice) the device object associated with this client. |
| """ |
| super(SnippetClient, self).__init__(app_name=package, ad=ad) |
| self.package = package |
| self._ad = ad |
| self._adb = ad.adb |
| self._proc = None |
| |
| @property |
| def is_alive(self): |
| """Is the client alive. |
| |
| The client is considered alive if there is a connection object held for |
| it. This is an approximation due to the following scenario: |
| |
| In the USB disconnect case, the host subprocess that kicked off the |
| snippet apk would die, but the snippet apk itself would continue |
| running on the device. |
| |
| The best approximation we can make is, the connection object has not |
| been explicitly torn down, so the client should be considered alive. |
| |
| Returns: |
| True if the client is considered alive, False otherwise. |
| """ |
| return self._conn is not None |
| |
| def _get_user_command_string(self): |
| """Gets the appropriate command argument for specifying user IDs. |
| |
| By default, `SnippetClient` operates within the current user. |
| |
| We don't add the `--user {ID}` arg when Android's SDK is below 24, |
| where multi-user support is not well implemented. |
| |
| Returns: |
| String, the command param section to be formatted into the adb |
| commands. |
| """ |
| sdk_int = int(self._ad.build_info['build_version_sdk']) |
| if sdk_int < 24: |
| return '' |
| return '--user %s' % self._adb.current_user_id |
| |
| def start_app_and_connect(self): |
| """Starts snippet apk on the device and connects to it. |
| |
| This wraps the main logic with safe handling |
| |
| Raises: |
| AppStartPreCheckError, when pre-launch checks fail. |
| """ |
| try: |
| self._start_app_and_connect() |
| except AppStartPreCheckError: |
| # Precheck errors don't need cleanup, directly raise. |
| raise |
| except Exception as e: |
| # Log the stacktrace of `e` as re-raising doesn't preserve trace. |
| self._ad.log.exception('Failed to start app and connect.') |
| # If errors happen, make sure we clean up before raising. |
| try: |
| self.stop_app() |
| except: |
| self._ad.log.exception( |
| 'Failed to stop app after failure to start and connect.') |
| # Explicitly raise the original error from starting app. |
| raise e |
| |
| def _start_app_and_connect(self): |
| """Starts snippet apk on the device and connects to it. |
| |
| After prechecks, this launches the snippet apk with an adb cmd in a |
| standing subprocess, checks the cmd response from the apk for protocol |
| version, then sets up the socket connection over adb port-forwarding. |
| |
| Args: |
| ProtocolVersionError, if protocol info or port info cannot be |
| retrieved from the snippet apk. |
| """ |
| self._check_app_installed() |
| self.disable_hidden_api_blacklist() |
| |
| persists_shell_cmd = self._get_persist_command() |
| # Use info here so people can follow along with the snippet startup |
| # process. Starting snippets can be slow, especially if there are |
| # multiple, and this avoids the perception that the framework is hanging |
| # for a long time doing nothing. |
| self.log.info('Launching snippet apk %s with protocol %d.%d', self.package, |
| _PROTOCOL_MAJOR_VERSION, _PROTOCOL_MINOR_VERSION) |
| cmd = _LAUNCH_CMD.format(shell_cmd=persists_shell_cmd, |
| user=self._get_user_command_string(), |
| snippet_package=self.package) |
| start_time = time.time() |
| self._proc = self._do_start_app(cmd) |
| |
| # Check protocol version and get the device port |
| line = self._read_protocol_line() |
| match = re.match('^SNIPPET START, PROTOCOL ([0-9]+) ([0-9]+)$', line) |
| if not match or match.group(1) != '1': |
| raise ProtocolVersionError(self._ad, line) |
| |
| line = self._read_protocol_line() |
| match = re.match('^SNIPPET SERVING, PORT ([0-9]+)$', line) |
| if not match: |
| raise ProtocolVersionError(self._ad, line) |
| self.device_port = int(match.group(1)) |
| |
| # Forward the device port to a new host port, and connect to that port |
| self.host_port = utils.get_available_host_port() |
| self._adb.forward(['tcp:%d' % self.host_port, 'tcp:%d' % self.device_port]) |
| self.connect() |
| |
| # Yaaay! We're done! |
| self.log.debug('Snippet %s started after %.1fs on host port %s', |
| self.package, |
| time.time() - start_time, self.host_port) |
| |
| def restore_app_connection(self, port=None): |
| """Restores the app after device got reconnected. |
| |
| Instead of creating new instance of the client: |
| - Uses the given port (or find a new available host_port if none is |
| given). |
| - Tries to connect to remote server with selected port. |
| |
| Args: |
| port: If given, this is the host port from which to connect to remote |
| device port. If not provided, find a new available port as host |
| port. |
| |
| Raises: |
| AppRestoreConnectionError: When the app was not able to be started. |
| """ |
| self.host_port = port or utils.get_available_host_port() |
| self._adb.forward(['tcp:%d' % self.host_port, 'tcp:%d' % self.device_port]) |
| try: |
| self.connect() |
| except: |
| # Log the original error and raise AppRestoreConnectionError. |
| self.log.exception('Failed to re-connect to app.') |
| raise jsonrpc_client_base.AppRestoreConnectionError( |
| self._ad, |
| ('Failed to restore app connection for %s at host port %s, ' |
| 'device port %s') % (self.package, self.host_port, self.device_port)) |
| |
| # Because the previous connection was lost, update self._proc |
| self._proc = None |
| self._restore_event_client() |
| |
| def stop_app(self): |
| # Kill the pending 'adb shell am instrument -w' process if there is one. |
| # Although killing the snippet apk would abort this process anyway, we |
| # want to call stop_standing_subprocess() to perform a health check, |
| # print the failure stack trace if there was any, and reap it from the |
| # process table. |
| self.log.debug('Stopping snippet apk %s', self.package) |
| try: |
| # Close the socket connection. |
| self.disconnect() |
| if self._proc: |
| utils.stop_standing_subprocess(self._proc) |
| self._proc = None |
| out = self._adb.shell( |
| _STOP_CMD.format( |
| snippet_package=self.package, |
| user=self._get_user_command_string())).decode('utf-8') |
| if 'OK (0 tests)' not in out: |
| raise errors.DeviceError( |
| self._ad, |
| 'Failed to stop existing apk. Unexpected output: %s' % out) |
| finally: |
| # Always clean up the adb port |
| self.clear_host_port() |
| |
| def _start_event_client(self): |
| """Overrides superclass.""" |
| event_client = SnippetClient(package=self.package, ad=self._ad) |
| event_client.host_port = self.host_port |
| event_client.device_port = self.device_port |
| event_client.connect(self.uid, jsonrpc_client_base.JsonRpcCommand.CONTINUE) |
| return event_client |
| |
| def _restore_event_client(self): |
| """Restores previously created event client.""" |
| if not self._event_client: |
| self._event_client = self._start_event_client() |
| return |
| self._event_client.host_port = self.host_port |
| self._event_client.device_port = self.device_port |
| self._event_client.connect() |
| |
| def _check_app_installed(self): |
| # Check that the Mobly Snippet app is installed for the current user. |
| user_id = self._adb.current_user_id |
| out = self._adb.shell('pm list package --user %s' % user_id) |
| if not utils.grep('^package:%s$' % self.package, out): |
| raise AppStartPreCheckError( |
| self._ad, |
| '%s is not installed for user %s.' % (self.package, user_id)) |
| # Check that the app is instrumented. |
| out = self._adb.shell('pm list instrumentation') |
| matched_out = utils.grep( |
| '^instrumentation:%s/%s' % |
| (self.package, _INSTRUMENTATION_RUNNER_PACKAGE), out) |
| if not matched_out: |
| raise AppStartPreCheckError( |
| self._ad, |
| '%s is installed, but it is not instrumented.' % self.package) |
| match = re.search(r'^instrumentation:(.*)\/(.*) \(target=(.*)\)$', |
| matched_out[0]) |
| target_name = match.group(3) |
| # Check that the instrumentation target is installed if it's not the |
| # same as the snippet package. |
| if target_name != self.package: |
| out = self._adb.shell('pm list package --user %s' % user_id) |
| if not utils.grep('^package:%s$' % target_name, out): |
| raise AppStartPreCheckError( |
| self._ad, |
| 'Instrumentation target %s is not installed for user %s.' % |
| (target_name, user_id)) |
| |
| def _do_start_app(self, launch_cmd): |
| adb_cmd = [adb.ADB] |
| if self._adb.serial: |
| adb_cmd += ['-s', self._adb.serial] |
| adb_cmd += ['shell', launch_cmd] |
| return utils.start_standing_subprocess(adb_cmd, shell=False) |
| |
| def _read_protocol_line(self): |
| """Reads the next line of instrumentation output relevant to snippets. |
| |
| This method will skip over lines that don't start with 'SNIPPET' or |
| 'INSTRUMENTATION_RESULT'. |
| |
| Returns: |
| (str) Next line of snippet-related instrumentation output, stripped. |
| |
| Raises: |
| jsonrpc_client_base.AppStartError: If EOF is reached without any |
| protocol lines being read. |
| """ |
| while True: |
| line = self._proc.stdout.readline().decode('utf-8') |
| if not line: |
| raise jsonrpc_client_base.AppStartError( |
| self._ad, 'Unexpected EOF waiting for app to start') |
| # readline() uses an empty string to mark EOF, and a single newline |
| # to mark regular empty lines in the output. Don't move the strip() |
| # call above the truthiness check, or this method will start |
| # considering any blank output line to be EOF. |
| line = line.strip() |
| if (line.startswith('INSTRUMENTATION_RESULT:') or |
| line.startswith('SNIPPET ')): |
| self.log.debug('Accepted line from instrumentation output: "%s"', line) |
| return line |
| self.log.debug('Discarded line from instrumentation output: "%s"', line) |
| |
| def _get_persist_command(self): |
| """Check availability and return path of command if available.""" |
| for command in [_SETSID_COMMAND, _NOHUP_COMMAND]: |
| try: |
| if command in self._adb.shell(['which', command]).decode('utf-8'): |
| return command |
| except adb.AdbError: |
| continue |
| self.log.warning( |
| 'No %s and %s commands available to launch instrument ' |
| 'persistently, tests that depend on UiAutomator and ' |
| 'at the same time performs USB disconnection may fail', _SETSID_COMMAND, |
| _NOHUP_COMMAND) |
| return '' |
| |
| def help(self, print_output=True): |
| """Calls the help RPC, which returns the list of RPC calls available. |
| |
| This RPC should normally be used in an interactive console environment |
| where the output should be printed instead of returned. Otherwise, |
| newlines will be escaped, which will make the output difficult to read. |
| |
| Args: |
| print_output: A bool for whether the output should be printed. |
| |
| Returns: |
| A str containing the help output otherwise None if print_output |
| wasn't set. |
| """ |
| help_text = self._rpc('help') |
| if print_output: |
| print(help_text) |
| else: |
| return help_text |