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