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"""Base class for clients that communicate with apps over a JSON RPC interface. 15 16The JSON protocol expected by this module is: 17 18.. code-block:: json 19 20 Request: 21 { 22 "id": <monotonically increasing integer containing the ID of 23 this request> 24 "method": <string containing the name of the method to execute> 25 "params": <JSON array containing the arguments to the method> 26 } 27 28 Response: 29 { 30 "id": <int id of request that this response maps to>, 31 "result": <Arbitrary JSON object containing the result of 32 executing the method. If the method could not be 33 executed or returned void, contains 'null'.>, 34 "error": <String containing the error thrown by executing the 35 method. If no error occurred, contains 'null'.> 36 "callback": <String that represents a callback ID used to 37 identify events associated with a particular 38 CallbackHandler object.> 39 } 40""" 41 42# When the Python library `socket.create_connection` call is made, it indirectly 43# calls `import encodings.idna` through the `socket.getaddrinfo` method. 44# However, this chain of function calls is apparently not thread-safe in 45# embedded Python environments. So, pre-emptively import and cache the encoder. 46# See https://bugs.python.org/issue17305 for more details. 47try: 48 import encodings.idna 49except ImportError: 50 # Some implementations of Python (e.g. IronPython) do not support the`idna` 51 # encoding, so ignore import failures based on that. 52 pass 53 54import abc 55import json 56import socket 57import threading 58 59from mobly.controllers.android_device_lib import callback_handler 60from mobly.controllers.android_device_lib import errors 61 62# UID of the 'unknown' jsonrpc session. Will cause creation of a new session. 63UNKNOWN_UID = -1 64 65# Maximum time to wait for the socket to open on the device. 66_SOCKET_CONNECTION_TIMEOUT = 60 67 68# Maximum time to wait for a response message on the socket. 69_SOCKET_READ_TIMEOUT = callback_handler.MAX_TIMEOUT 70 71# Maximum logging length of Rpc response in DEBUG level when verbose logging is 72# off. 73_MAX_RPC_RESP_LOGGING_LENGTH = 1024 74 75 76class Error(errors.DeviceError): 77 pass 78 79 80class AppStartError(Error): 81 """Raised when the app is not able to be started.""" 82 83 84class AppRestoreConnectionError(Error): 85 """Raised when failed to restore app from disconnection.""" 86 87 88class ApiError(Error): 89 """Raised when remote API reports an error.""" 90 91 92class ProtocolError(Error): 93 """Raised when there is some error in exchanging data with server.""" 94 NO_RESPONSE_FROM_HANDSHAKE = 'No response from handshake.' 95 NO_RESPONSE_FROM_SERVER = ('No response from server. ' 96 'Check the device logcat for crashes.') 97 MISMATCHED_API_ID = 'RPC request-response ID mismatch.' 98 99 100class JsonRpcCommand: 101 """Commands that can be invoked on all jsonrpc clients. 102 103 INIT: Initializes a new session. 104 CONTINUE: Creates a connection. 105 """ 106 INIT = 'initiate' 107 CONTINUE = 'continue' 108 109 110class JsonRpcClientBase(abc.ABC): 111 """Base class for jsonrpc clients that connect to remote servers. 112 113 Connects to a remote device running a jsonrpc-compatible app. Before opening 114 a connection a port forward must be setup to go over usb. This be done using 115 adb.forward([local, remote]). Once the port has been forwarded it can be 116 used in this object as the port of communication. 117 118 Attributes: 119 host_port: (int) The host port of this RPC client. 120 device_port: (int) The device port of this RPC client. 121 app_name: (str) The user-visible name of the app being communicated 122 with. 123 uid: (int) The uid of this session. 124 """ 125 126 def __init__(self, app_name, ad): 127 """ 128 Args: 129 app_name: (str) The user-visible name of the app being communicated 130 with. 131 ad: (AndroidDevice) The device object associated with a client. 132 """ 133 self.host_port = None 134 self.device_port = None 135 self.app_name = app_name 136 self._ad = ad 137 self.log = self._ad.log 138 self.uid = None 139 self._client = None # prevent close errors on connect failure 140 self._conn = None 141 self._counter = None 142 self._lock = threading.Lock() 143 self._event_client = None 144 self.verbose_logging = True 145 146 def __del__(self): 147 self.disconnect() 148 149 # Methods to be implemented by subclasses. 150 151 def start_app_and_connect(self): 152 """Starts the server app on the android device and connects to it. 153 154 After this, the self.host_port and self.device_port attributes must be 155 set. 156 157 Must be implemented by subclasses. 158 159 Raises: 160 AppStartError: When the app was not able to be started. 161 """ 162 163 def stop_app(self): 164 """Kills any running instance of the app. 165 166 Must be implemented by subclasses. 167 """ 168 169 def restore_app_connection(self, port=None): 170 """Reconnects to the app after device USB was disconnected. 171 172 Instead of creating new instance of the client: 173 - Uses the given port (or finds a new available host_port if none is 174 given). 175 - Tries to connect to remote server with selected port. 176 177 Must be implemented by subclasses. 178 179 Args: 180 port: If given, this is the host port from which to connect to remote 181 device port. If not provided, find a new available port as host 182 port. 183 184 Raises: 185 AppRestoreConnectionError: When the app was not able to be 186 reconnected. 187 """ 188 189 def _start_event_client(self): 190 """Starts a separate JsonRpc client to the same session for propagating 191 events. 192 193 This is an optional function that should only implement if the client 194 utilizes the snippet event mechanism. 195 196 Returns: 197 A JsonRpc Client object that connects to the same session as the 198 one on which this function is called. 199 """ 200 201 # Rest of the client methods. 202 203 def connect(self, uid=UNKNOWN_UID, cmd=JsonRpcCommand.INIT): 204 """Opens a connection to a JSON RPC server. 205 206 Opens a connection to a remote client. The connection attempt will time 207 out if it takes longer than _SOCKET_CONNECTION_TIMEOUT seconds. Each 208 subsequent operation over this socket will time out after 209 _SOCKET_READ_TIMEOUT seconds as well. 210 211 Args: 212 uid: int, The uid of the session to join, or UNKNOWN_UID to start a 213 new session. 214 cmd: JsonRpcCommand, The command to use for creating the connection. 215 216 Raises: 217 IOError: Raised when the socket times out from io error 218 socket.timeout: Raised when the socket waits to long for connection. 219 ProtocolError: Raised when there is an error in the protocol. 220 """ 221 self._counter = self._id_counter() 222 try: 223 self._conn = socket.create_connection(('localhost', self.host_port), 224 _SOCKET_CONNECTION_TIMEOUT) 225 except ConnectionRefusedError as err: 226 # Retry using '127.0.0.1' for IPv4 enabled machines that only resolve 227 # 'localhost' to '[::1]'. 228 self.log.debug( 229 'Failed to connect to localhost, trying 127.0.0.1: {}'.format( 230 str(err))) 231 self._conn = socket.create_connection(('127.0.0.1', self.host_port), 232 _SOCKET_CONNECTION_TIMEOUT) 233 234 self._conn.settimeout(_SOCKET_READ_TIMEOUT) 235 self._client = self._conn.makefile(mode='brw') 236 237 resp = self._cmd(cmd, uid) 238 if not resp: 239 raise ProtocolError(self._ad, ProtocolError.NO_RESPONSE_FROM_HANDSHAKE) 240 result = json.loads(str(resp, encoding='utf8')) 241 if result['status']: 242 self.uid = result['uid'] 243 else: 244 self.uid = UNKNOWN_UID 245 246 def disconnect(self): 247 """Close the connection to the snippet server on the device. 248 249 This is a unilateral disconnect from the client side, without tearing down 250 the snippet server running on the device. 251 252 The connection to the snippet server can be re-established by calling 253 `SnippetClient.restore_app_connection`. 254 """ 255 try: 256 if self._conn: 257 self._conn.close() 258 self._conn = None 259 finally: 260 # Always clear the host port as part of the disconnect step. 261 self.clear_host_port() 262 263 def clear_host_port(self): 264 """Stops the adb port forwarding of the host port used by this client. 265 """ 266 if self.host_port: 267 self._ad.adb.forward(['--remove', 'tcp:%d' % self.host_port]) 268 self.host_port = None 269 270 def _client_send(self, msg): 271 """Sends an Rpc message through the connection. 272 273 Args: 274 msg: string, the message to send. 275 276 Raises: 277 Error: a socket error occurred during the send. 278 """ 279 try: 280 self._client.write(msg.encode("utf8") + b'\n') 281 self._client.flush() 282 self.log.debug('Snippet sent %s.', msg) 283 except socket.error as e: 284 raise Error( 285 self._ad, 286 'Encountered socket error "%s" sending RPC message "%s"' % (e, msg)) 287 288 def _client_receive(self): 289 """Receives the server's response of an Rpc message. 290 291 Returns: 292 Raw byte string of the response. 293 294 Raises: 295 Error: a socket error occurred during the read. 296 """ 297 try: 298 response = self._client.readline() 299 if self.verbose_logging: 300 self.log.debug('Snippet received: %s', response) 301 else: 302 if _MAX_RPC_RESP_LOGGING_LENGTH >= len(response): 303 self.log.debug('Snippet received: %s', response) 304 else: 305 self.log.debug('Snippet received: %s... %d chars are truncated', 306 response[:_MAX_RPC_RESP_LOGGING_LENGTH], 307 len(response) - _MAX_RPC_RESP_LOGGING_LENGTH) 308 return response 309 except socket.error as e: 310 raise Error(self._ad, 311 'Encountered socket error reading RPC response "%s"' % e) 312 313 def _cmd(self, command, uid=None): 314 """Send a command to the server. 315 316 Args: 317 command: str, The name of the command to execute. 318 uid: int, the uid of the session to send the command to. 319 320 Returns: 321 The line that was written back. 322 """ 323 if not uid: 324 uid = self.uid 325 self._client_send(json.dumps({'cmd': command, 'uid': uid})) 326 return self._client_receive() 327 328 def _rpc(self, method, *args): 329 """Sends an rpc to the app. 330 331 Args: 332 method: str, The name of the method to execute. 333 args: any, The args of the method. 334 335 Returns: 336 The result of the rpc. 337 338 Raises: 339 ProtocolError: Something went wrong with the protocol. 340 ApiError: The rpc went through, however executed with errors. 341 """ 342 with self._lock: 343 apiid = next(self._counter) 344 data = {'id': apiid, 'method': method, 'params': args} 345 request = json.dumps(data) 346 self._client_send(request) 347 response = self._client_receive() 348 if not response: 349 raise ProtocolError(self._ad, ProtocolError.NO_RESPONSE_FROM_SERVER) 350 result = json.loads(str(response, encoding='utf8')) 351 if result['error']: 352 raise ApiError(self._ad, result['error']) 353 if result['id'] != apiid: 354 raise ProtocolError(self._ad, ProtocolError.MISMATCHED_API_ID) 355 if result.get('callback') is not None: 356 if self._event_client is None: 357 self._event_client = self._start_event_client() 358 return callback_handler.CallbackHandler(callback_id=result['callback'], 359 event_client=self._event_client, 360 ret_value=result['result'], 361 method_name=method, 362 ad=self._ad) 363 return result['result'] 364 365 def disable_hidden_api_blacklist(self): 366 """If necessary and possible, disables hidden api blacklist.""" 367 version_codename = self._ad.build_info['build_version_codename'] 368 sdk_version = int(self._ad.build_info['build_version_sdk']) 369 # we check version_codename in addition to sdk_version because P builds 370 # in development report sdk_version 27, but still enforce the blacklist. 371 if self._ad.is_rootable and (sdk_version >= 28 or version_codename == 'P'): 372 self._ad.adb.shell( 373 'settings put global hidden_api_blacklist_exemptions "*"') 374 375 def __getattr__(self, name): 376 """Wrapper for python magic to turn method calls into RPC calls.""" 377 378 def rpc_call(*args): 379 return self._rpc(name, *args) 380 381 return rpc_call 382 383 def _id_counter(self): 384 i = 0 385 while True: 386 yield i 387 i += 1 388 389 def set_snippet_client_verbose_logging(self, verbose): 390 """Switches verbose logging. True for logging full RPC response. 391 392 By default it will only write max_rpc_return_value_length for Rpc return 393 strings. If you need to see full message returned from Rpc, please turn 394 on verbose logging. 395 396 max_rpc_return_value_length will set to 1024 by default, the length 397 contains full Rpc response in Json format, included 1st element "id". 398 399 Args: 400 verbose: bool. If True, turns on verbose logging, if False turns off 401 """ 402 self._ad.log.info('Set verbose logging to %s.', verbose) 403 self.verbose_logging = verbose 404