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