• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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