| # 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. |
| """Base class for clients that communicate with apps over a JSON RPC interface. |
| |
| The JSON protocol expected by this module is: |
| |
| .. code-block:: json |
| |
| Request: |
| { |
| "id": <monotonically increasing integer containing the ID of |
| this request> |
| "method": <string containing the name of the method to execute> |
| "params": <JSON array containing the arguments to the method> |
| } |
| |
| Response: |
| { |
| "id": <int id of request that this response maps to>, |
| "result": <Arbitrary JSON object containing the result of |
| executing the method. If the method could not be |
| executed or returned void, contains 'null'.>, |
| "error": <String containing the error thrown by executing the |
| method. If no error occurred, contains 'null'.> |
| "callback": <String that represents a callback ID used to |
| identify events associated with a particular |
| CallbackHandler object.> |
| } |
| """ |
| |
| # When the Python library `socket.create_connection` call is made, it indirectly |
| # calls `import encodings.idna` through the `socket.getaddrinfo` method. |
| # However, this chain of function calls is apparently not thread-safe in |
| # embedded Python environments. So, pre-emptively import and cache the encoder. |
| # See https://bugs.python.org/issue17305 for more details. |
| try: |
| import encodings.idna |
| except ImportError: |
| # Some implementations of Python (e.g. IronPython) do not support the`idna` |
| # encoding, so ignore import failures based on that. |
| pass |
| |
| import abc |
| import json |
| import socket |
| import sys |
| import threading |
| |
| from mobly.controllers.android_device_lib import callback_handler |
| from mobly.controllers.android_device_lib import errors |
| |
| # UID of the 'unknown' jsonrpc session. Will cause creation of a new session. |
| UNKNOWN_UID = -1 |
| |
| # Maximum time to wait for the socket to open on the device. |
| _SOCKET_CONNECTION_TIMEOUT = 60 |
| |
| # Maximum time to wait for a response message on the socket. |
| _SOCKET_READ_TIMEOUT = callback_handler.MAX_TIMEOUT |
| |
| # Maximum logging length of Rpc response in DEBUG level when verbose logging is |
| # off. |
| _MAX_RPC_RESP_LOGGING_LENGTH = 1024 |
| |
| |
| class Error(errors.DeviceError): |
| pass |
| |
| |
| class AppStartError(Error): |
| """Raised when the app is not able to be started.""" |
| |
| |
| class AppRestoreConnectionError(Error): |
| """Raised when failed to restore app from disconnection.""" |
| |
| |
| class ApiError(Error): |
| """Raised when remote API reports an error.""" |
| |
| |
| class ProtocolError(Error): |
| """Raised when there is some error in exchanging data with server.""" |
| NO_RESPONSE_FROM_HANDSHAKE = 'No response from handshake.' |
| NO_RESPONSE_FROM_SERVER = 'No response from server.' |
| MISMATCHED_API_ID = 'RPC request-response ID mismatch.' |
| |
| |
| class JsonRpcCommand(object): |
| """Commands that can be invoked on all jsonrpc clients. |
| |
| INIT: Initializes a new session. |
| CONTINUE: Creates a connection. |
| """ |
| INIT = 'initiate' |
| CONTINUE = 'continue' |
| |
| |
| class JsonRpcClientBase(abc.ABC): |
| """Base class for jsonrpc clients that connect to remote servers. |
| |
| Connects to a remote device running a jsonrpc-compatible app. Before opening |
| a connection a port forward must be setup to go over usb. This be done using |
| adb.forward([local, remote]). Once the port has been forwarded it can be |
| used in this object as the port of communication. |
| |
| Attributes: |
| host_port: (int) The host port of this RPC client. |
| device_port: (int) The device port of this RPC client. |
| app_name: (str) The user-visible name of the app being communicated |
| with. |
| uid: (int) The uid of this session. |
| """ |
| |
| def __init__(self, app_name, ad): |
| """ |
| Args: |
| app_name: (str) The user-visible name of the app being communicated |
| with. |
| ad: (AndroidDevice) The device object associated with a client. |
| """ |
| self.host_port = None |
| self.device_port = None |
| self.app_name = app_name |
| self._ad = ad |
| self.log = self._ad.log |
| self.uid = None |
| self._client = None # prevent close errors on connect failure |
| self._conn = None |
| self._counter = None |
| self._lock = threading.Lock() |
| self._event_client = None |
| self.verbose_logging = True |
| |
| def __del__(self): |
| self.disconnect() |
| |
| # Methods to be implemented by subclasses. |
| |
| def start_app_and_connect(self): |
| """Starts the server app on the android device and connects to it. |
| |
| After this, the self.host_port and self.device_port attributes must be |
| set. |
| |
| Must be implemented by subclasses. |
| |
| Raises: |
| AppStartError: When the app was not able to be started. |
| """ |
| |
| def stop_app(self): |
| """Kills any running instance of the app. |
| |
| Must be implemented by subclasses. |
| """ |
| |
| def restore_app_connection(self, port=None): |
| """Reconnects to the app after device USB was disconnected. |
| |
| Instead of creating new instance of the client: |
| - Uses the given port (or finds a new available host_port if none is |
| given). |
| - Tries to connect to remote server with selected port. |
| |
| Must be implemented by subclasses. |
| |
| 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 |
| reconnected. |
| """ |
| |
| def _start_event_client(self): |
| """Starts a separate JsonRpc client to the same session for propagating |
| events. |
| |
| This is an optional function that should only implement if the client |
| utilizes the snippet event mechanism. |
| |
| Returns: |
| A JsonRpc Client object that connects to the same session as the |
| one on which this function is called. |
| """ |
| |
| # Rest of the client methods. |
| |
| def connect(self, uid=UNKNOWN_UID, cmd=JsonRpcCommand.INIT): |
| """Opens a connection to a JSON RPC server. |
| |
| Opens a connection to a remote client. The connection attempt will time |
| out if it takes longer than _SOCKET_CONNECTION_TIMEOUT seconds. Each |
| subsequent operation over this socket will time out after |
| _SOCKET_READ_TIMEOUT seconds as well. |
| |
| Args: |
| uid: int, The uid of the session to join, or UNKNOWN_UID to start a |
| new session. |
| cmd: JsonRpcCommand, The command to use for creating the connection. |
| |
| Raises: |
| IOError: Raised when the socket times out from io error |
| socket.timeout: Raised when the socket waits to long for connection. |
| ProtocolError: Raised when there is an error in the protocol. |
| """ |
| self._counter = self._id_counter() |
| try: |
| self._conn = socket.create_connection(('localhost', self.host_port), |
| _SOCKET_CONNECTION_TIMEOUT) |
| except ConnectionRefusedError as err: |
| # Retry using '127.0.0.1' for IPv4 enabled machines that only resolve |
| # 'localhost' to '[::1]'. |
| self.log.debug( |
| 'Failed to connect to localhost, trying 127.0.0.1: {}'.format( |
| str(err))) |
| self._conn = socket.create_connection(('127.0.0.1', self.host_port), |
| _SOCKET_CONNECTION_TIMEOUT) |
| |
| self._conn.settimeout(_SOCKET_READ_TIMEOUT) |
| self._client = self._conn.makefile(mode='brw') |
| |
| resp = self._cmd(cmd, uid) |
| if not resp: |
| raise ProtocolError(self._ad, ProtocolError.NO_RESPONSE_FROM_HANDSHAKE) |
| result = json.loads(str(resp, encoding='utf8')) |
| if result['status']: |
| self.uid = result['uid'] |
| else: |
| self.uid = UNKNOWN_UID |
| |
| def disconnect(self): |
| """Close the connection to the remote client.""" |
| if self._conn: |
| self._conn.close() |
| self._conn = None |
| |
| def clear_host_port(self): |
| """Stops the adb port forwarding of the host port used by this client. |
| """ |
| if self.host_port: |
| self._adb.forward(['--remove', 'tcp:%d' % self.host_port]) |
| self.host_port = None |
| |
| def _client_send(self, msg): |
| """Sends an Rpc message through the connection. |
| |
| Args: |
| msg: string, the message to send. |
| |
| Raises: |
| Error: a socket error occurred during the send. |
| """ |
| try: |
| self._client.write(msg.encode("utf8") + b'\n') |
| self._client.flush() |
| self.log.debug('Snippet sent %s.', msg) |
| except socket.error as e: |
| raise Error( |
| self._ad, |
| 'Encountered socket error "%s" sending RPC message "%s"' % (e, msg)) |
| |
| def _client_receive(self): |
| """Receives the server's response of an Rpc message. |
| |
| Returns: |
| Raw byte string of the response. |
| |
| Raises: |
| Error: a socket error occurred during the read. |
| """ |
| try: |
| response = self._client.readline() |
| if self.verbose_logging: |
| self.log.debug('Snippet received: %s', response) |
| else: |
| if _MAX_RPC_RESP_LOGGING_LENGTH >= len(response): |
| self.log.debug('Snippet received: %s', response) |
| else: |
| self.log.debug('Snippet received: %s... %d chars are truncated', |
| response[:_MAX_RPC_RESP_LOGGING_LENGTH], |
| len(response) - _MAX_RPC_RESP_LOGGING_LENGTH) |
| return response |
| except socket.error as e: |
| raise Error(self._ad, |
| 'Encountered socket error reading RPC response "%s"' % e) |
| |
| def _cmd(self, command, uid=None): |
| """Send a command to the server. |
| |
| Args: |
| command: str, The name of the command to execute. |
| uid: int, the uid of the session to send the command to. |
| |
| Returns: |
| The line that was written back. |
| """ |
| if not uid: |
| uid = self.uid |
| self._client_send(json.dumps({'cmd': command, 'uid': uid})) |
| return self._client_receive() |
| |
| def _rpc(self, method, *args): |
| """Sends an rpc to the app. |
| |
| Args: |
| method: str, The name of the method to execute. |
| args: any, The args of the method. |
| |
| Returns: |
| The result of the rpc. |
| |
| Raises: |
| ProtocolError: Something went wrong with the protocol. |
| ApiError: The rpc went through, however executed with errors. |
| """ |
| with self._lock: |
| apiid = next(self._counter) |
| data = {'id': apiid, 'method': method, 'params': args} |
| request = json.dumps(data) |
| self._client_send(request) |
| response = self._client_receive() |
| if not response: |
| raise ProtocolError(self._ad, ProtocolError.NO_RESPONSE_FROM_SERVER) |
| result = json.loads(str(response, encoding='utf8')) |
| if result['error']: |
| raise ApiError(self._ad, result['error']) |
| if result['id'] != apiid: |
| raise ProtocolError(self._ad, ProtocolError.MISMATCHED_API_ID) |
| if result.get('callback') is not None: |
| if self._event_client is None: |
| self._event_client = self._start_event_client() |
| return callback_handler.CallbackHandler(callback_id=result['callback'], |
| event_client=self._event_client, |
| ret_value=result['result'], |
| method_name=method, |
| ad=self._ad) |
| return result['result'] |
| |
| def disable_hidden_api_blacklist(self): |
| """If necessary and possible, disables hidden api blacklist.""" |
| version_codename = self._ad.build_info['build_version_codename'] |
| sdk_version = int(self._ad.build_info['build_version_sdk']) |
| # we check version_codename in addition to sdk_version because P builds |
| # in development report sdk_version 27, but still enforce the blacklist. |
| if self._ad.is_rootable and (sdk_version >= 28 or version_codename == 'P'): |
| self._ad.adb.shell( |
| 'settings put global hidden_api_blacklist_exemptions "*"') |
| |
| def __getattr__(self, name): |
| """Wrapper for python magic to turn method calls into RPC calls.""" |
| |
| def rpc_call(*args): |
| return self._rpc(name, *args) |
| |
| return rpc_call |
| |
| def _id_counter(self): |
| i = 0 |
| while True: |
| yield i |
| i += 1 |
| |
| def set_snippet_client_verbose_logging(self, verbose): |
| """Switches verbose logging. True for logging full RPC response. |
| |
| By default it will only write max_rpc_return_value_length for Rpc return |
| strings. If you need to see full message returned from Rpc, please turn |
| on verbose logging. |
| |
| max_rpc_return_value_length will set to 1024 by default, the length |
| contains full Rpc response in Json format, included 1st element "id". |
| |
| Args: |
| verbose: bool. If True, turns on verbose logging, if False turns off |
| """ |
| self._ad.log.info('Set verbose logging to %s.', verbose) |
| self.verbose_logging = verbose |