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