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