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