Snap for 8746144 from bb92044f58ace7197608519fce81075dfe392d62 to mainline-cellbroadcast-release

Change-Id: I7644ee7804696d359019f3df106d4dfaedec8d40
diff --git a/Android.bp b/Android.bp
new file mode 100644
index 0000000..bd02340
--- /dev/null
+++ b/Android.bp
@@ -0,0 +1,29 @@
+//
+// Copyright (C) 2022 The Android Open Source Project
+//
+// 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.
+
+package {
+    default_applicable_licenses: ["external_python_mobly_license"],
+}
+
+license {
+    name: "external_python_mobly_license",
+    visibility: [":__subpackages__"],
+    license_kinds: [
+        "SPDX-license-identifier-Apache-2.0",
+    ],
+    license_text: [
+        "LICENSE",
+    ],
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 61bbb3e..aaf6c90 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,19 @@
 # Mobly Release History
 
 
+## Mobly Release 1.11.1: Support Test Case `repeat` and `retry`.
+
+### New
+* Native support for `repeat` and `retry` of test cases.
+* Additional assertion APIs.
+* `android_device` now picks up `fastboot` devices if given `*`.
+
+### Fixes
+* Removed the usage of `psutil` in favor of native `Py3` features.
+
+[Full list of changes](https://github.com/google/mobly/milestone/26?closed=1)
+
+
 ## Mobly Release 1.11: Py2 Deprecation and Repeat/Retry Support
 
 This release focuses on code quality improvement, refactoring, and legacy
@@ -20,6 +33,8 @@
 * Various improvements in Android device controller
 * More metadata collected for test runs
 
+[Full list of changes](https://github.com/google/mobly/milestone/25?closed=1)
+
 
 ## Mobly Release 1.10.1: Incremental fixes
 
diff --git a/mobly/Android.bp b/mobly/Android.bp
new file mode 100644
index 0000000..c6bc3f4
--- /dev/null
+++ b/mobly/Android.bp
@@ -0,0 +1,40 @@
+// Copyright 2022 Google Inc. All rights reserved.
+//
+// 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.
+package {
+    default_applicable_licenses: ["external_python_mobly_license"],
+}
+
+python_library {
+    name: "mobly",
+    host_supported: true,
+    srcs: [
+        "**/*.py",
+    ],
+    version: {
+        py2: {
+            enabled: false,
+        },
+        py3: {
+            enabled: true,
+        },
+    },
+    libs: [
+        "py-portpicker",
+        "py-timeout-decorator",
+        "pyyaml",
+        "pyserial",
+        "typing_extensions",
+    ],
+    pkg_path: "mobly",
+}
diff --git a/mobly/controllers/android_device.py b/mobly/controllers/android_device.py
index d786738..40b47a4 100644
--- a/mobly/controllers/android_device.py
+++ b/mobly/controllers/android_device.py
@@ -149,7 +149,8 @@
     serials: list of strings, the serials of all the devices that are expected
       to exist.
   """
-  valid_ad_identifiers = list_adb_devices() + list_adb_devices_by_usb_id()
+  valid_ad_identifiers = (list_adb_devices() + list_adb_devices_by_usb_id() +
+                          list_fastboot_devices())
   for serial in serials:
     if serial not in valid_ad_identifiers:
       raise Error(f'Android device serial "{serial}" is specified in '
@@ -241,6 +242,9 @@
   """List all android devices connected to the computer that are in in
   fastboot mode. These are detected by fastboot.
 
+  This function doesn't raise any error if `fastboot` binary doesn't exist,
+  because `FastbootProxy` itself doesn't raise any error.
+
   Returns:
     A list of android device serials. Empty if there's none.
   """
@@ -433,7 +437,7 @@
 
 class BuildInfoConstants(enum.Enum):
   """Enums for build info constants used for AndroidDevice build info.
-  
+
   Attributes:
     build_info_key: The key used for the build_info dictionary in AndroidDevice.
     system_prop_key: The key used for getting the build info from system
@@ -787,7 +791,7 @@
       device is in bootloader mode.
     """
     if self.is_bootloader:
-      self.log.error('Device is in fastboot mode, could not get build ' 'info.')
+      self.log.error('Device is in fastboot mode, could not get build info.')
       return
     if self._build_info is None or self._is_rebooting:
       info = {}
diff --git a/mobly/controllers/android_device_lib/adb.py b/mobly/controllers/android_device_lib/adb.py
index eaaeefb..84051cd 100644
--- a/mobly/controllers/android_device_lib/adb.py
+++ b/mobly/controllers/android_device_lib/adb.py
@@ -159,7 +159,7 @@
   def __init__(self, serial=''):
     self.serial = serial
 
-  def _exec_cmd(self, args, shell, timeout, stderr):
+  def _exec_cmd(self, args, shell, timeout, stderr) -> bytes:
     """Executes adb commands.
 
     Args:
@@ -200,7 +200,7 @@
                      ret_code=ret,
                      serial=self.serial)
 
-  def _execute_and_process_stdout(self, args, shell, handler):
+  def _execute_and_process_stdout(self, args, shell, handler) -> bytes:
     """Executes adb commands and processes the stdout with a handler.
 
     Args:
@@ -285,12 +285,12 @@
           adb_cmd.extend(args)
     return adb_cmd
 
-  def _exec_adb_cmd(self, name, args, shell, timeout, stderr):
+  def _exec_adb_cmd(self, name, args, shell, timeout, stderr) -> bytes:
     adb_cmd = self._construct_adb_cmd(name, args, shell=shell)
     out = self._exec_cmd(adb_cmd, shell=shell, timeout=timeout, stderr=stderr)
     return out
 
-  def _execute_adb_and_process_stdout(self, name, args, shell, handler):
+  def _execute_adb_and_process_stdout(self, name, args, shell, handler) -> bytes:
     adb_cmd = self._construct_adb_cmd(name, args, shell=shell)
     err = self._execute_and_process_stdout(adb_cmd,
                                            shell=shell,
@@ -324,7 +324,7 @@
     return results
 
   @property
-  def current_user_id(self):
+  def current_user_id(self) -> int:
     """The integer ID of the current Android user.
 
     Some adb commands require specifying a user ID to work properly. Use
@@ -343,7 +343,7 @@
     # Multi-user is not supported in SDK < 21, only user 0 exists.
     return 0
 
-  def connect(self, address):
+  def connect(self, address) -> bytes:
     """Executes the `adb connect` command with proper status checking.
 
     Args:
@@ -414,7 +414,7 @@
         time.sleep(DEFAULT_GETPROPS_RETRY_SLEEP_SEC)
     return results
 
-  def has_shell_command(self, command):
+  def has_shell_command(self, command) -> bool:
     """Checks to see if a given check command exists on the device.
 
     Args:
@@ -431,7 +431,7 @@
       # an exit code > 1.
       return False
 
-  def forward(self, args=None, shell=False):
+  def forward(self, args=None, shell=False) -> bytes:
     with ADB_PORT_LOCK:
       return self._exec_adb_cmd('forward',
                                 args,
@@ -439,7 +439,7 @@
                                 timeout=None,
                                 stderr=None)
 
-  def instrument(self, package, options=None, runner=None, handler=None):
+  def instrument(self, package, options=None, runner=None, handler=None) -> bytes:
     """Runs an instrumentation command on the device.
 
     This is a convenience wrapper to avoid parameter formatting.
@@ -496,7 +496,7 @@
                                                   shell=False,
                                                   handler=handler)
 
-  def root(self):
+  def root(self) -> bytes:
     """Enables ADB root mode on the device.
 
     This method will retry to execute the command `adb root` when an
@@ -529,7 +529,7 @@
 
   def __getattr__(self, name):
 
-    def adb_call(args=None, shell=False, timeout=None, stderr=None):
+    def adb_call(args=None, shell=False, timeout=None, stderr=None) -> bytes:
       """Wrapper for an ADB command.
 
       Args:
diff --git a/mobly/controllers/android_device_lib/snippet_client_v2.py b/mobly/controllers/android_device_lib/snippet_client_v2.py
new file mode 100644
index 0000000..be3b98a
--- /dev/null
+++ b/mobly/controllers/android_device_lib/snippet_client_v2.py
@@ -0,0 +1,341 @@
+# Copyright 2022 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.
+"""Snippet Client V2 for Interacting with Snippet Server on Android Device."""
+
+import re
+
+from mobly import utils
+from mobly.controllers.android_device_lib import adb
+from mobly.controllers.android_device_lib import errors as android_device_lib_errors
+from mobly.snippet import client_base
+from mobly.snippet import errors
+
+# The package of the instrumentation runner used for mobly snippet
+_INSTRUMENTATION_RUNNER_PACKAGE = 'com.google.android.mobly.snippet.SnippetRunner'
+
+# The command template to start the snippet server
+_LAUNCH_CMD = (
+    '{shell_cmd} am instrument {user} -w -e action start {snippet_package}/'
+    f'{_INSTRUMENTATION_RUNNER_PACKAGE}')
+
+# The command template to stop the snippet server
+_STOP_CMD = ('am instrument {user} -w -e action stop {snippet_package}/'
+             f'{_INSTRUMENTATION_RUNNER_PACKAGE}')
+
+# Major version of the launch and communication protocol being used by this
+# client.
+# Incrementing this means that compatibility with clients using the older
+# version is broken. Avoid breaking compatibility unless there is no other
+# choice.
+_PROTOCOL_MAJOR_VERSION = 1
+
+# Minor version of the launch and communication protocol.
+# Increment this when new features are added to the launch and communication
+# protocol that are backwards compatible with the old protocol and don't break
+# existing clients.
+_PROTOCOL_MINOR_VERSION = 0
+
+# Test that uses UiAutomation requires the shell session to be maintained while
+# test is in progress. However, this requirement does not hold for the test that
+# deals with device disconnection (Once device disconnects, the shell session
+# that started the instrument ends, and UiAutomation fails with error:
+# "UiAutomation not connected"). To keep the shell session and redirect
+# stdin/stdout/stderr, use "setsid" or "nohup" while launching the
+# instrumentation test. Because these commands may not be available in every
+# Android system, try to use it only if at least one exists.
+_SETSID_COMMAND = 'setsid'
+
+_NOHUP_COMMAND = 'nohup'
+
+
+class SnippetClientV2(client_base.ClientBase):
+  """Snippet client V2 for interacting with snippet server on Android Device.
+
+  See base class documentation for a list of public attributes and communication
+  protocols.
+
+  For a description of the launch protocols, see the documentation in
+  mobly-snippet-lib, SnippetRunner.java.
+  """
+
+  def __init__(self, package, ad):
+    """Initializes the instance of Snippet Client V2.
+
+    Args:
+      package: str, see base class.
+      ad: AndroidDevice, the android device object associated with this client.
+    """
+    super().__init__(package=package, device=ad)
+    self._adb = ad.adb
+    self._user_id = None
+    self._proc = None
+
+  @property
+  def user_id(self):
+    """The user id to use for this snippet client.
+
+    All the operations of the snippet client should be used for a particular
+    user. For more details, see the Android documentation of testing
+    multiple users.
+
+    Thus this value is cached and, once set, does not change through the
+    lifecycles of this snippet client object. This caching also reduces the
+    number of adb calls needed.
+
+    Although for now self._user_id won't be modified once set, we use
+    `property` to avoid issuing adb commands in the constructor.
+
+    Returns:
+      An integer of the user id.
+    """
+    if self._user_id is None:
+      self._user_id = self._adb.current_user_id
+    return self._user_id
+
+  def before_starting_server(self):
+    """Performs the preparation steps before starting the remote server.
+
+    This function performs following preparation steps:
+    * Validate that the Mobly Snippet app is available on the device.
+    * Disable hidden api blocklist if necessary and possible.
+
+    Raises:
+      errors.ServerStartPreCheckError: if the server app is not installed
+        for the current user.
+    """
+    self._validate_snippet_app_on_device()
+    self._disable_hidden_api_blocklist()
+
+  def _validate_snippet_app_on_device(self):
+    """Validates the Mobly Snippet app is available on the device.
+
+    To run as an instrumentation test, the Mobly Snippet app must already be
+    installed and instrumented on the Android device.
+
+    Raises:
+      errors.ServerStartPreCheckError: if the server app is not installed
+        for the current user.
+    """
+    # Validate that the Mobly Snippet app is installed for the current user.
+    out = self._adb.shell(f'pm list package --user {self.user_id}')
+    if not utils.grep(f'^package:{self.package}$', out):
+      raise errors.ServerStartPreCheckError(
+          self._device,
+          f'{self.package} is not installed for user {self.user_id}.')
+
+    # Validate that the app is instrumented.
+    out = self._adb.shell('pm list instrumentation')
+    matched_out = utils.grep(
+        f'^instrumentation:{self.package}/{_INSTRUMENTATION_RUNNER_PACKAGE}',
+        out)
+    if not matched_out:
+      raise errors.ServerStartPreCheckError(
+          self._device,
+          f'{self.package} is installed, but it is not instrumented.')
+    match = re.search(r'^instrumentation:(.*)\/(.*) \(target=(.*)\)$',
+                      matched_out[0])
+    target_name = match.group(3)
+    # Validate that the instrumentation target is installed if it's not the
+    # same as the snippet package.
+    if target_name != self.package:
+      out = self._adb.shell(f'pm list package --user {self.user_id}')
+      if not utils.grep(f'^package:{target_name}$', out):
+        raise errors.ServerStartPreCheckError(
+            self._device,
+            f'Instrumentation target {target_name} is not installed for user '
+            f'{self.user_id}.')
+
+  def _disable_hidden_api_blocklist(self):
+    """If necessary and possible, disables hidden api blocklist."""
+    sdk_version = int(self._device.build_info['build_version_sdk'])
+    if self._device.is_rootable and sdk_version >= 28:
+      self._device.adb.shell(
+          'settings put global hidden_api_blacklist_exemptions "*"')
+
+  def start_server(self):
+    """Starts the server on the remote device.
+
+    This function starts the snippet server with adb command, checks the
+    protocol version of the server, parses device port from the server
+    output and sets it to self.device_port.
+
+    Raises:
+      errors.ServerStartProtocolError: if the protocol reported by the server
+        startup process is unknown.
+      errors.ServerStartError: if failed to start the server or process the
+        server output.
+    """
+    persists_shell_cmd = self._get_persisting_command()
+    self.log.debug('Snippet server for package %s is using protocol %d.%d',
+                   self.package, _PROTOCOL_MAJOR_VERSION,
+                   _PROTOCOL_MINOR_VERSION)
+    cmd = _LAUNCH_CMD.format(shell_cmd=persists_shell_cmd,
+                             user=self._get_user_command_string(),
+                             snippet_package=self.package)
+    self._proc = self._run_adb_cmd(cmd)
+
+    # Check protocol version and get the device port
+    line = self._read_protocol_line()
+    match = re.match('^SNIPPET START, PROTOCOL ([0-9]+) ([0-9]+)$', line)
+    if not match or int(match.group(1)) != _PROTOCOL_MAJOR_VERSION:
+      raise errors.ServerStartProtocolError(self._device, line)
+
+    line = self._read_protocol_line()
+    match = re.match('^SNIPPET SERVING, PORT ([0-9]+)$', line)
+    if not match:
+      raise errors.ServerStartProtocolError(self._device, line)
+    self.device_port = int(match.group(1))
+
+  def _run_adb_cmd(self, cmd):
+    """Starts a long-running adb subprocess and returns it immediately."""
+    adb_cmd = [adb.ADB]
+    if self._adb.serial:
+      adb_cmd += ['-s', self._adb.serial]
+    adb_cmd += ['shell', cmd]
+    return utils.start_standing_subprocess(adb_cmd, shell=False)
+
+  def _get_persisting_command(self):
+    """Returns the path of a persisting command if available."""
+    for command in [_SETSID_COMMAND, _NOHUP_COMMAND]:
+      try:
+        if command in self._adb.shell(['which', command]).decode('utf-8'):
+          return command
+      except adb.AdbError:
+        continue
+
+    self.log.warning(
+        'No %s and %s commands available to launch instrument '
+        'persistently, tests that depend on UiAutomator and '
+        'at the same time perform USB disconnections may fail.',
+        _SETSID_COMMAND, _NOHUP_COMMAND)
+    return ''
+
+  def _get_user_command_string(self):
+    """Gets the appropriate command argument for specifying device user ID.
+
+    By default, this client operates within the current user. We
+    don't add the `--user {ID}` argument when Android's SDK is below 24,
+    where multi-user support is not well implemented.
+
+    Returns:
+      A string of the command argument section to be formatted into
+      adb commands.
+    """
+    sdk_version = int(self._device.build_info['build_version_sdk'])
+    if sdk_version < 24:
+      return ''
+    return f'--user {self.user_id}'
+
+  def _read_protocol_line(self):
+    """Reads the next line of instrumentation output relevant to snippets.
+
+    This method will skip over lines that don't start with 'SNIPPET ' or
+    'INSTRUMENTATION_RESULT:'.
+
+    Returns:
+      A string for the next line of snippet-related instrumentation output,
+        stripped.
+
+    Raises:
+      errors.ServerStartError: If EOF is reached without any protocol lines
+        being read.
+    """
+    while True:
+      line = self._proc.stdout.readline().decode('utf-8')
+      if not line:
+        raise errors.ServerStartError(
+            self._device, 'Unexpected EOF when waiting for server to start.')
+
+      # readline() uses an empty string to mark EOF, and a single newline
+      # to mark regular empty lines in the output. Don't move the strip()
+      # call above the truthiness check, or this method will start
+      # considering any blank output line to be EOF.
+      line = line.strip()
+      if (line.startswith('INSTRUMENTATION_RESULT:') or
+          line.startswith('SNIPPET ')):
+        self.log.debug('Accepted line from instrumentation output: "%s"', line)
+        return line
+
+      self.log.debug('Discarded line from instrumentation output: "%s"', line)
+
+  def stop(self):
+    """Releases all the resources acquired in `initialize`.
+
+    This function releases following resources:
+    * Stop the standing server subprocess running on the host side.
+    * Stop the snippet server running on the device side.
+
+    Raises:
+      android_device_lib_errors.DeviceError: if the server exited with errors on
+        the device side.
+    """
+    # TODO(mhaoli): This function is only partially implemented because we
+    # have not implemented the functionality of making connections in this
+    # class.
+    self.log.debug('Stopping snippet package %s.', self.package)
+    self._stop_server()
+    self.log.debug('Snippet package %s stopped.', self.package)
+
+  def _stop_server(self):
+    """Releases all the resources acquired in `start_server`.
+
+    Raises:
+      android_device_lib_errors.DeviceError: if the server exited with errors on
+        the device side.
+    """
+    # Although killing the snippet server would abort this subprocess anyway, we
+    # want to call stop_standing_subprocess() to perform a health check,
+    # print the failure stack trace if there was any, and reap it from the
+    # process table. Note that it's much more important to ensure releasing all
+    # the allocated resources on the host side than on the remote device side.
+
+    # Stop the standing server subprocess running on the host side.
+    if self._proc:
+      utils.stop_standing_subprocess(self._proc)
+      self._proc = None
+
+    # Send the stop signal to the server running on the device side.
+    out = self._adb.shell(
+        _STOP_CMD.format(snippet_package=self.package,
+                         user=self._get_user_command_string())).decode('utf-8')
+
+    if 'OK (0 tests)' not in out:
+      raise android_device_lib_errors.DeviceError(
+          self._device,
+          f'Failed to stop existing apk. Unexpected output: {out}.')
+
+  # TODO(mhaoli): Temporally override these abstract methods so that we can
+  # initialize the instances in unit tests. We are implementing these functions
+  # in the next PR as soon as possible.
+  def make_connection(self):
+    raise NotImplementedError('To be implemented.')
+
+  def close_connection(self):
+    raise NotImplementedError('To be implemented.')
+
+  def __del__(self):
+    # Override the destructor to not call close_connection for now.
+    pass
+
+  def send_rpc_request(self, request):
+    raise NotImplementedError('To be implemented.')
+
+  def check_server_proc_running(self):
+    raise NotImplementedError('To be implemented.')
+
+  def handle_callback(self, callback_id, ret_value, rpc_func_name):
+    raise NotImplementedError('To be implemented.')
+
+  def restore_server_connection(self, port=None):
+    raise NotImplementedError('To be implemented.')
diff --git a/mobly/snippet/__init__.py b/mobly/snippet/__init__.py
new file mode 100644
index 0000000..ac3f9e6
--- /dev/null
+++ b/mobly/snippet/__init__.py
@@ -0,0 +1,13 @@
+# Copyright 2022 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.
diff --git a/mobly/snippet/client_base.py b/mobly/snippet/client_base.py
new file mode 100644
index 0000000..0009f20
--- /dev/null
+++ b/mobly/snippet/client_base.py
@@ -0,0 +1,438 @@
+# Copyright 2022 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.
+"""The JSON RPC client base for communicating with snippet servers.
+
+The JSON RPC protocol expected by this module is:
+
+.. code-block:: json
+
+  Request:
+  {
+    'id': <Required. Monotonically increasing integer containing the ID of this
+          request.>,
+    'method': <Required. String containing the name of the method to execute.>,
+    'params': <Required. JSON array containing the arguments to the method,
+              `null` if no positional arguments for the RPC method.>,
+    'kwargs': <Optional. JSON dict containing the keyword arguments for the
+              method, `null` if no positional arguments for the RPC method.>,
+  }
+
+  Response:
+  {
+    'error': <Required. String containing the error thrown by executing the
+             method, `null` if no error occurred.>,
+    'id': <Required. Int id of request that this response maps to.>,
+    'result': <Required. Arbitrary JSON object containing the result of
+              executing the method, `null` if the method could not be executed
+              or returned void.>,
+    'callback': <Required. String that represents a callback ID used to
+                identify events associated with a particular CallbackHandler
+                object, `null` if this is not an asynchronous RPC.>,
+  }
+"""
+
+import abc
+import json
+import threading
+import time
+
+from mobly.snippet import errors
+
+# Maximum logging length of RPC response in DEBUG level when verbose logging is
+# off.
+_MAX_RPC_RESP_LOGGING_LENGTH = 1024
+
+# The required field names of RPC response.
+RPC_RESPONSE_REQUIRED_FIELDS = ('id', 'error', 'result', 'callback')
+
+
+class ClientBase(abc.ABC):
+  """Base class for JSON RPC clients that connect to snippet servers.
+
+  Connects to a remote device running a JSON RPC compatible server. Users call
+  the function `start_server` to start the server on the remote device before
+  sending any RPC. After sending all RPCs, users call the function `stop`
+  to stop the snippet server and release all the requested resources.
+
+  Attributes:
+    package: str, the user-visible name of the snippet library being
+      communicated with.
+    host_port: int, the host port of this RPC client.
+    device_port: int, the device port of this RPC client.
+    log: Logger, the logger of the corresponding device controller.
+    verbose_logging: bool, if True, prints more detailed log
+      information. Default is True.
+  """
+
+  def __init__(self, package, device):
+    """Initializes the instance of ClientBase.
+
+    Args:
+      package: str, the user-visible name of the snippet library being
+        communicated with.
+      device: DeviceController, the device object associated with a client.
+    """
+
+    self.package = package
+    self.host_port = None
+    self.device_port = None
+    self.log = device.log
+    self.verbose_logging = True
+    self._device = device
+    self._counter = None
+    self._lock = threading.Lock()
+    self._event_client = None
+
+  def __del__(self):
+    self.close_connection()
+
+  def initialize(self):
+    """Initializes the snippet client to interact with the remote device.
+
+    This function contains following stages:
+      1. preparing to start the snippet server.
+      2. starting the snippet server on the remote device.
+      3. making a connection to the snippet server.
+
+    After this, the self.host_port and self.device_port attributes must be
+    set.
+
+    Raises:
+      errors.ProtocolError: something went wrong when exchanging data with the
+        server.
+      errors.ServerStartPreCheckError: when prechecks for starting the server
+        failed.
+      errors.ServerStartError: when failed to start the snippet server.
+    """
+
+    # Use log.info here so people can follow along with the initialization
+    # process. Initialization can be slow, especially if there are
+    # multiple snippets, this avoids the perception that the framework
+    # is hanging for a long time doing nothing.
+    self.log.info('Initializing the snippet package %s.', self.package)
+    start_time = time.perf_counter()
+
+    self.log.debug('Preparing to start the snippet server of %s.', self.package)
+    self.before_starting_server()
+
+    try:
+      self.log.debug('Starting the snippet server of %s.', self.package)
+      self.start_server()
+
+      self.log.debug('Making a connection to the snippet server of %s.',
+                     self.package)
+      self._make_connection()
+
+    except Exception:
+      self.log.error(
+          'Error occurred trying to start and connect to the snippet server '
+          'of %s.', self.package)
+      try:
+        self.stop()
+      except Exception:  # pylint: disable=broad-except
+        # Only prints this exception and re-raises the original exception
+        self.log.exception(
+            'Failed to stop the snippet package %s after failure to start '
+            'and connect.', self.package)
+
+      raise
+
+    self.log.debug(
+        'Snippet package %s initialized after %.1fs on host port %d.',
+        self.package,
+        time.perf_counter() - start_time, self.host_port)
+
+  @abc.abstractmethod
+  def before_starting_server(self):
+    """Performs the preparation steps before starting the remote server.
+
+    For example, subclass can check or modify the device settings at this
+    stage.
+
+    Raises:
+      errors.ServerStartPreCheckError: when prechecks for starting the server
+        failed.
+    """
+
+  @abc.abstractmethod
+  def start_server(self):
+    """Starts the server on the remote device.
+
+    The client has completed the preparations, so the client calls this
+    function to start the server.
+    """
+
+  def _make_connection(self):
+    """Proxy function of make_connection.
+
+    This function resets the RPC id counter before calling `make_connection`.
+    """
+    self._counter = self._id_counter()
+    self.make_connection()
+
+  @abc.abstractmethod
+  def make_connection(self):
+    """Makes a connection to the snippet server on the remote device.
+
+    This function makes a connection to the server and sends a handshake
+    request to ensure the server is available for upcoming RPCs.
+
+    There are two types of connections used by snippet clients:
+    * The client makes a new connection each time it needs to send an RPC.
+    * The client makes a connection in this stage and uses it for all the RPCs.
+      In this case, the client should implement `close_connection` to close
+      the connection.
+
+    This function uses self.host_port for communicating with the server. If
+    self.host_port is 0 or None, this function finds an available host port to
+    make the connection and set self.host_port to the found port.
+
+    Raises:
+      errors.ProtocolError: something went wrong when exchanging data with the
+        server.
+    """
+
+  def __getattr__(self, name):
+    """Wrapper for python magic to turn method calls into RPCs."""
+
+    def rpc_call(*args, **kwargs):
+      return self._rpc(name, *args, **kwargs)
+
+    return rpc_call
+
+  def _id_counter(self):
+    """Returns an id generator."""
+    i = 0
+    while True:
+      yield i
+      i += 1
+
+  def set_snippet_client_verbose_logging(self, verbose):
+    """Switches verbose logging. True for logging full RPC responses.
+
+    By default it will write full messages returned from RPCs. Turning off the
+    verbose logging will result in writing no more than
+    _MAX_RPC_RESP_LOGGING_LENGTH characters per RPC returned string.
+
+    _MAX_RPC_RESP_LOGGING_LENGTH will be set to 1024 by default. The length
+    contains the full RPC response in JSON format, not just the RPC result
+    field.
+
+    Args:
+      verbose: bool, if True, turns on verbose logging, otherwise turns off.
+    """
+    self.log.info('Sets verbose logging to %s.', verbose)
+    self.verbose_logging = verbose
+
+  @abc.abstractmethod
+  def restore_server_connection(self, port=None):
+    """Reconnects to the server after the device was disconnected.
+
+    Instead of creating a new instance of the client:
+      - Uses the given port (or finds a new available host_port if 0 or None is
+      given).
+      - Tries to connect to the remote server with the selected port.
+
+    Args:
+      port: int, if given, this is the host port from which to connect to the
+        remote device port. Otherwise, finds a new available port as host
+        port.
+
+    Raises:
+      errors.ServerRestoreConnectionError: when failed to restore the connection
+        to the snippet server.
+    """
+
+  def _rpc(self, rpc_func_name, *args, **kwargs):
+    """Sends an RPC to the server.
+
+    Args:
+      rpc_func_name: str, the name of the snippet function to execute on the
+        server.
+      *args: any, the positional arguments of the RPC request.
+      **kwargs: any, the keyword arguments of the RPC request.
+
+    Returns:
+      The result of the RPC.
+
+    Raises:
+      errors.ProtocolError: something went wrong when exchanging data with the
+        server.
+      errors.ApiError: the RPC went through, however executed with errors.
+    """
+    try:
+      self.check_server_proc_running()
+    except Exception:
+      self.log.error(
+          'Server process running check failed, skip sending RPC method(%s).',
+          rpc_func_name)
+      raise
+
+    with self._lock:
+      rpc_id = next(self._counter)
+      request = self._gen_rpc_request(rpc_id, rpc_func_name, *args, **kwargs)
+
+      self.log.debug('Sending RPC request %s.', request)
+      response = self.send_rpc_request(request)
+      self.log.debug('RPC request sent.')
+
+      if self.verbose_logging or _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)
+
+    response_decoded = self._decode_response_string_and_validate_format(
+        rpc_id, response)
+    return self._handle_rpc_response(rpc_func_name, response_decoded)
+
+  @abc.abstractmethod
+  def check_server_proc_running(self):
+    """Checks whether the server is still running.
+
+    If the server is not running, it throws an error. As this function is called
+    each time the client tries to send an RPC, this should be a quick check
+    without affecting performance. Otherwise it is fine to not check anything.
+
+    Raises:
+      errors.ServerDiedError: if the server died.
+    """
+
+  def _gen_rpc_request(self, rpc_id, rpc_func_name, *args, **kwargs):
+    """Generates the JSON RPC request.
+
+    In the generated JSON string, the fields are sorted by keys in ascending
+    order.
+
+    Args:
+      rpc_id: int, the id of this RPC.
+      rpc_func_name: str, the name of the snippet function to execute
+        on the server.
+      *args: any, the positional arguments of the RPC.
+      **kwargs: any, the keyword arguments of the RPC.
+
+    Returns:
+      A string of the JSON RPC request.
+    """
+    data = {'id': rpc_id, 'method': rpc_func_name, 'params': args}
+    if kwargs:
+      data['kwargs'] = kwargs
+    return json.dumps(data, sort_keys=True)
+
+  @abc.abstractmethod
+  def send_rpc_request(self, request):
+    """Sends the JSON RPC request to the server and gets a response.
+
+    Note that the request and response are both in string format. So if the
+    connection with server provides interfaces in bytes format, please
+    transform them to string in the implementation of this function.
+
+    Args:
+      request: str, a string of the RPC request.
+
+    Returns:
+      A string of the RPC response.
+
+    Raises:
+      errors.ProtocolError: something went wrong when exchanging data with the
+        server.
+    """
+
+  def _decode_response_string_and_validate_format(self, rpc_id, response):
+    """Decodes response JSON string to python dict and validates its format.
+
+    Args:
+      rpc_id: int, the actual id of this RPC. It should be the same with the id
+        in the response, otherwise throws an error.
+      response: str, the JSON string of the RPC response.
+
+    Returns:
+      A dict decoded from the response JSON string.
+
+    Raises:
+      errors.ProtocolError: if the response format is invalid.
+    """
+    if not response:
+      raise errors.ProtocolError(self._device,
+                                 errors.ProtocolError.NO_RESPONSE_FROM_SERVER)
+
+    result = json.loads(response)
+    for field_name in RPC_RESPONSE_REQUIRED_FIELDS:
+      if field_name not in result:
+        raise errors.ProtocolError(
+            self._device,
+            errors.ProtocolError.RESPONSE_MISSING_FIELD % field_name)
+
+    if result['id'] != rpc_id:
+      raise errors.ProtocolError(self._device,
+                                 errors.ProtocolError.MISMATCHED_API_ID)
+
+    return result
+
+  def _handle_rpc_response(self, rpc_func_name, response):
+    """Handles the content of RPC response.
+
+    If the RPC response contains error information, it throws an error. If the
+    RPC is asynchronous, it creates and returns a callback handler
+    object. Otherwise, it returns the result field of the response.
+
+    Args:
+      rpc_func_name: str, the name of the snippet function that this RPC
+        triggered on the snippet server.
+      response: dict, the object decoded from the response JSON string.
+
+    Returns:
+      The result of the RPC. If synchronous RPC, it is the result field of the
+      response. If asynchronous RPC, it is the callback handler object.
+
+    Raises:
+      errors.ApiError: if the snippet function executed with errors.
+    """
+
+    if response['error']:
+      raise errors.ApiError(self._device, response['error'])
+    if response['callback'] is not None:
+      return self.handle_callback(response['callback'], response['result'],
+                                  rpc_func_name)
+    return response['result']
+
+  @abc.abstractmethod
+  def handle_callback(self, callback_id, ret_value, rpc_func_name):
+    """Creates a callback handler for the asynchronous RPC.
+
+    Args:
+      callback_id: str, the callback ID for creating a callback handler object.
+      ret_value: any, the result field of the RPC response.
+      rpc_func_name: str, the name of the snippet function executed on the
+        server.
+
+    Returns:
+      The callback handler object.
+    """
+
+  @abc.abstractmethod
+  def stop(self):
+    """Releases all the resources acquired in `initialize`."""
+
+  @abc.abstractmethod
+  def close_connection(self):
+    """Closes the connection to the snippet server on the device.
+
+    This is a unilateral closing from the client side, without tearing down
+    the snippet server running on the device.
+
+    The connection to the snippet server can be re-established by calling
+    `restore_server_connection`.
+    """
diff --git a/mobly/snippet/errors.py b/mobly/snippet/errors.py
new file mode 100644
index 0000000..764aea4
--- /dev/null
+++ b/mobly/snippet/errors.py
@@ -0,0 +1,62 @@
+# Copyright 2022 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.
+"""Module for errors thrown from snippet client objects."""
+# TODO(mhaoli): Package `mobly.snippet` should not import errors from
+# android_device_lib. However, android_device_lib.DeviceError is the base error
+# for the errors thrown from Android snippet clients and device controllers.
+# We should resolve this legacy problem.
+from mobly.controllers.android_device_lib import errors
+
+
+class Error(errors.DeviceError):
+  """Root error type for snippet clients."""
+
+
+class ServerRestoreConnectionError(Error):
+  """Raised when failed to restore the connection with the snippet server."""
+
+
+class ServerStartError(Error):
+  """Raised when failed to start the snippet server."""
+
+
+class ServerStartProtocolError(ServerStartError):
+  """Raised when protocol reported by the server startup process is unknown."""
+
+
+class ServerStartPreCheckError(Error):
+  """Raised when prechecks for starting the snippet server failed.
+
+  Here are some precheck examples:
+  * Whether the required software is installed on the device.
+  * Whether the configuration file required by the server startup process
+    is available.
+  """
+
+
+class ApiError(Error):
+  """Raised when remote API reported an error."""
+
+
+class ProtocolError(Error):
+  """Raised when there was an error in exchanging data with server."""
+  NO_RESPONSE_FROM_HANDSHAKE = 'No response from handshake.'
+  NO_RESPONSE_FROM_SERVER = ('No response from server. '
+                             'Check the device logcat for crashes.')
+  MISMATCHED_API_ID = 'RPC request-response ID mismatch.'
+  RESPONSE_MISSING_FIELD = 'Missing required field in the RPC response: %s.'
+
+
+class ServerDiedError(Error):
+  """Raised if the snippet server died before all tests finish."""
diff --git a/setup.cfg b/setup.cfg
index fac2172..3b9eef9 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,5 @@
 [metadata]
-description-file = README.md
+description_file = README.md
 [tool:pytest]
 python_classes = *Test
 python_files = *_test.py
diff --git a/setup.py b/setup.py
index a6fbaea..30f3ce2 100755
--- a/setup.py
+++ b/setup.py
@@ -18,7 +18,7 @@
 import sys
 
 install_requires = [
-    'portpicker', 'pyserial', 'pyyaml', 'timeout_decorator', 'typing_extensions'
+    'portpicker', 'pyserial', 'pyyaml', 'timeout_decorator', 'typing_extensions>=4.1.1'
 ]
 
 if platform.system() == 'Windows':
@@ -44,13 +44,13 @@
 def main():
   setuptools.setup(
       name='mobly',
-      version='1.11',
+      version='1.11.1',
       maintainer='Ang Li',
       maintainer_email='mobly-github@googlegroups.com',
       description='Automation framework for special end-to-end test cases',
       license='Apache2.0',
       url='https://github.com/google/mobly',
-      download_url='https://github.com/google/mobly/tarball/1.11',
+      download_url='https://github.com/google/mobly/tarball/1.11.1',
       packages=setuptools.find_packages(exclude=['tests']),
       include_package_data=False,
       scripts=['tools/sl4a_shell.py', 'tools/snippet_shell.py'],
diff --git a/tests/mobly/controllers/android_device_lib/snippet_client_v2_test.py b/tests/mobly/controllers/android_device_lib/snippet_client_v2_test.py
new file mode 100644
index 0000000..b97774b
--- /dev/null
+++ b/tests/mobly/controllers/android_device_lib/snippet_client_v2_test.py
@@ -0,0 +1,394 @@
+# Copyright 2017 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.
+"""Unit tests for mobly.controllers.android_device_lib.snippet_client_v2."""
+
+import unittest
+from unittest import mock
+
+from mobly.controllers.android_device_lib import adb
+from mobly.controllers.android_device_lib import errors as android_device_lib_errors
+from mobly.controllers.android_device_lib import snippet_client_v2
+from mobly.snippet import errors
+from tests.lib import mock_android_device
+
+MOCK_PACKAGE_NAME = 'some.package.name'
+MOCK_SERVER_PATH = f'{MOCK_PACKAGE_NAME}/{snippet_client_v2._INSTRUMENTATION_RUNNER_PACKAGE}'
+MOCK_USER_ID = 0
+
+
+class SnippetClientV2Test(unittest.TestCase):
+  """Unit tests for SnippetClientV2."""
+
+  def _make_client(self, adb_proxy=None, mock_properties=None):
+    adb_proxy = adb_proxy or mock_android_device.MockAdbProxy(
+        instrumented_packages=[
+            (MOCK_PACKAGE_NAME,
+             snippet_client_v2._INSTRUMENTATION_RUNNER_PACKAGE,
+             MOCK_PACKAGE_NAME)
+        ],
+        mock_properties=mock_properties)
+
+    device = mock.Mock()
+    device.adb = adb_proxy
+    device.adb.current_user_id = MOCK_USER_ID
+    device.build_info = {
+        'build_version_codename':
+            adb_proxy.getprop('ro.build.version.codename'),
+        'build_version_sdk':
+            adb_proxy.getprop('ro.build.version.sdk'),
+    }
+
+    self.client = snippet_client_v2.SnippetClientV2(MOCK_PACKAGE_NAME, device)
+
+  def _make_client_with_extra_adb_properties(self, extra_properties):
+    mock_properties = mock_android_device.DEFAULT_MOCK_PROPERTIES.copy()
+    mock_properties.update(extra_properties)
+    self._make_client(mock_properties=mock_properties)
+
+  def _mock_server_process_starting_response(self, mock_start_subprocess,
+                                             resp_lines):
+    mock_proc = mock_start_subprocess.return_value
+    mock_proc.stdout.readline.side_effect = resp_lines
+
+  def test_check_app_installed_normally(self):
+    """Tests that app checker runs normally when app installed correctly."""
+    self._make_client()
+    self.client._validate_snippet_app_on_device()
+
+  def test_check_app_installed_fail_app_not_installed(self):
+    """Tests that app checker fails without installing app."""
+    self._make_client(mock_android_device.MockAdbProxy())
+    expected_msg = f'.* {MOCK_PACKAGE_NAME} is not installed.'
+    with self.assertRaisesRegex(errors.ServerStartPreCheckError, expected_msg):
+      self.client._validate_snippet_app_on_device()
+
+  def test_check_app_installed_fail_not_instrumented(self):
+    """Tests that app checker fails without instrumenting app."""
+    self._make_client(
+        mock_android_device.MockAdbProxy(
+            installed_packages=[MOCK_PACKAGE_NAME]))
+    expected_msg = (
+        f'.* {MOCK_PACKAGE_NAME} is installed, but it is not instrumented.')
+    with self.assertRaisesRegex(errors.ServerStartPreCheckError, expected_msg):
+      self.client._validate_snippet_app_on_device()
+
+  def test_check_app_installed_fail_instrumentation_not_installed(self):
+    """Tests that app checker fails without installing instrumentation."""
+    self._make_client(
+        mock_android_device.MockAdbProxy(instrumented_packages=[(
+            MOCK_PACKAGE_NAME,
+            snippet_client_v2._INSTRUMENTATION_RUNNER_PACKAGE,
+            'not.installed')]))
+    expected_msg = ('.* Instrumentation target not.installed is not installed.')
+    with self.assertRaisesRegex(errors.ServerStartPreCheckError, expected_msg):
+      self.client._validate_snippet_app_on_device()
+
+  @mock.patch.object(mock_android_device.MockAdbProxy, 'shell')
+  def test_disable_hidden_api_normally(self, mock_shell_func):
+    """Tests the disabling hidden api process works normally."""
+    self._make_client_with_extra_adb_properties({
+        'ro.build.version.codename': 'S',
+        'ro.build.version.sdk': '31',
+    })
+    self.client._device.is_rootable = True
+    self.client._disable_hidden_api_blocklist()
+    mock_shell_func.assert_called_with(
+        'settings put global hidden_api_blacklist_exemptions "*"')
+
+  @mock.patch.object(mock_android_device.MockAdbProxy, 'shell')
+  def test_disable_hidden_api_low_sdk(self, mock_shell_func):
+    """Tests it doesn't disable hidden api with low SDK."""
+    self._make_client_with_extra_adb_properties({
+        'ro.build.version.codename': 'O',
+        'ro.build.version.sdk': '26',
+    })
+    self.client._device.is_rootable = True
+    self.client._disable_hidden_api_blocklist()
+    mock_shell_func.assert_not_called()
+
+  @mock.patch.object(mock_android_device.MockAdbProxy, 'shell')
+  def test_disable_hidden_api_non_rootable(self, mock_shell_func):
+    """Tests it doesn't disable hidden api with non-rootable device."""
+    self._make_client_with_extra_adb_properties({
+        'ro.build.version.codename': 'S',
+        'ro.build.version.sdk': '31',
+    })
+    self.client._device.is_rootable = False
+    self.client._disable_hidden_api_blocklist()
+    mock_shell_func.assert_not_called()
+
+  @mock.patch('mobly.controllers.android_device_lib.snippet_client_v2.'
+              'utils.start_standing_subprocess')
+  @mock.patch.object(mock_android_device.MockAdbProxy,
+                     'shell',
+                     return_value=b'setsid')
+  def test_start_server_with_user_id(self, mock_adb, mock_start_subprocess):
+    """Tests that `--user` is added to starting command with SDK >= 24."""
+    self._make_client_with_extra_adb_properties({'ro.build.version.sdk': '30'})
+    self._mock_server_process_starting_response(
+        mock_start_subprocess,
+        resp_lines=[
+            b'SNIPPET START, PROTOCOL 1 234', b'SNIPPET SERVING, PORT 1234'
+        ])
+
+    self.client.start_server()
+    start_cmd_list = [
+        'adb', 'shell',
+        (f'setsid am instrument --user {MOCK_USER_ID} -w -e action start '
+         f'{MOCK_SERVER_PATH}')
+    ]
+    self.assertListEqual(mock_start_subprocess.call_args_list,
+                         [mock.call(start_cmd_list, shell=False)])
+    self.assertEqual(self.client.device_port, 1234)
+    mock_adb.assert_called_with(['which', 'setsid'])
+
+  @mock.patch('mobly.controllers.android_device_lib.snippet_client_v2.'
+              'utils.start_standing_subprocess')
+  @mock.patch.object(mock_android_device.MockAdbProxy,
+                     'shell',
+                     return_value=b'setsid')
+  def test_start_server_without_user_id(self, mock_adb, mock_start_subprocess):
+    """Tests that `--user` is not added to starting command on SDK < 24."""
+    self._make_client_with_extra_adb_properties({'ro.build.version.sdk': '21'})
+    self._mock_server_process_starting_response(
+        mock_start_subprocess,
+        resp_lines=[
+            b'SNIPPET START, PROTOCOL 1 234', b'SNIPPET SERVING, PORT 1234'
+        ])
+
+    self.client.start_server()
+    start_cmd_list = [
+        'adb', 'shell',
+        f'setsid am instrument  -w -e action start {MOCK_SERVER_PATH}'
+    ]
+    self.assertListEqual(mock_start_subprocess.call_args_list,
+                         [mock.call(start_cmd_list, shell=False)])
+    mock_adb.assert_called_with(['which', 'setsid'])
+    self.assertEqual(self.client.device_port, 1234)
+
+  @mock.patch('mobly.controllers.android_device_lib.snippet_client_v2.'
+              'utils.start_standing_subprocess')
+  @mock.patch.object(mock_android_device.MockAdbProxy,
+                     'shell',
+                     side_effect=adb.AdbError('cmd', 'stdout', 'stderr',
+                                              'ret_code'))
+  def test_start_server_without_persisting_commands(self, mock_adb,
+                                                    mock_start_subprocess):
+    """Checks the starting server command without persisting commands."""
+    self._make_client()
+    self._mock_server_process_starting_response(
+        mock_start_subprocess,
+        resp_lines=[
+            b'SNIPPET START, PROTOCOL 1 234', b'SNIPPET SERVING, PORT 1234'
+        ])
+
+    self.client.start_server()
+    start_cmd_list = [
+        'adb', 'shell',
+        (f' am instrument --user {MOCK_USER_ID} -w -e action start '
+         f'{MOCK_SERVER_PATH}')
+    ]
+    self.assertListEqual(mock_start_subprocess.call_args_list,
+                         [mock.call(start_cmd_list, shell=False)])
+    mock_adb.assert_has_calls(
+        [mock.call(['which', 'setsid']),
+         mock.call(['which', 'nohup'])])
+    self.assertEqual(self.client.device_port, 1234)
+
+  @mock.patch('mobly.controllers.android_device_lib.snippet_client_v2.'
+              'utils.start_standing_subprocess')
+  def test_start_server_with_nohup(self, mock_start_subprocess):
+    """Checks the starting server command with nohup."""
+    self._make_client()
+    self._mock_server_process_starting_response(
+        mock_start_subprocess,
+        resp_lines=[
+            b'SNIPPET START, PROTOCOL 1 234', b'SNIPPET SERVING, PORT 1234'
+        ])
+
+    def _mocked_shell(arg):
+      if 'nohup' in arg:
+        return b'nohup'
+      raise adb.AdbError('cmd', 'stdout', 'stderr', 'ret_code')
+
+    self.client._adb.shell = _mocked_shell
+
+    self.client.start_server()
+    start_cmd_list = [
+        'adb', 'shell',
+        (f'nohup am instrument --user {MOCK_USER_ID} -w -e action start '
+         f'{MOCK_SERVER_PATH}')
+    ]
+    self.assertListEqual(mock_start_subprocess.call_args_list,
+                         [mock.call(start_cmd_list, shell=False)])
+    self.assertEqual(self.client.device_port, 1234)
+
+  @mock.patch('mobly.controllers.android_device_lib.snippet_client_v2.'
+              'utils.start_standing_subprocess')
+  def test_start_server_with_setsid(self, mock_start_subprocess):
+    """Checks the starting server command with setsid."""
+    self._make_client()
+    self._mock_server_process_starting_response(
+        mock_start_subprocess,
+        resp_lines=[
+            b'SNIPPET START, PROTOCOL 1 234', b'SNIPPET SERVING, PORT 1234'
+        ])
+
+    def _mocked_shell(arg):
+      if 'setsid' in arg:
+        return b'setsid'
+      raise adb.AdbError('cmd', 'stdout', 'stderr', 'ret_code')
+
+    self.client._adb.shell = _mocked_shell
+    self.client.start_server()
+    start_cmd_list = [
+        'adb', 'shell',
+        (f'setsid am instrument --user {MOCK_USER_ID} -w -e action start '
+         f'{MOCK_SERVER_PATH}')
+    ]
+    self.assertListEqual(mock_start_subprocess.call_args_list,
+                         [mock.call(start_cmd_list, shell=False)])
+    self.assertEqual(self.client.device_port, 1234)
+
+  @mock.patch('mobly.controllers.android_device_lib.snippet_client_v2.'
+              'utils.start_standing_subprocess')
+  def test_start_server_server_crash(self, mock_start_standing_subprocess):
+    """Tests that starting server process crashes."""
+    self._make_client()
+    self._mock_server_process_starting_response(
+        mock_start_standing_subprocess,
+        resp_lines=[b'INSTRUMENTATION_RESULT: shortMsg=Process crashed.\n'])
+    with self.assertRaisesRegex(
+        errors.ServerStartProtocolError,
+        'INSTRUMENTATION_RESULT: shortMsg=Process crashed.'):
+      self.client.start_server()
+
+  @mock.patch('mobly.controllers.android_device_lib.snippet_client_v2.'
+              'utils.start_standing_subprocess')
+  def test_start_server_unknown_protocol_version(
+      self, mock_start_standing_subprocess):
+    """Tests that starting server process reports unknown protocol version."""
+    self._make_client()
+    self._mock_server_process_starting_response(
+        mock_start_standing_subprocess,
+        resp_lines=[b'SNIPPET START, PROTOCOL 99 0\n'])
+    with self.assertRaisesRegex(errors.ServerStartProtocolError,
+                                'SNIPPET START, PROTOCOL 99 0'):
+      self.client.start_server()
+
+  @mock.patch('mobly.controllers.android_device_lib.snippet_client_v2.'
+              'utils.start_standing_subprocess')
+  def test_start_server_invalid_device_port(self,
+                                            mock_start_standing_subprocess):
+    """Tests that starting server process reports invalid device port."""
+    self._make_client()
+    self._mock_server_process_starting_response(
+        mock_start_standing_subprocess,
+        resp_lines=[
+            b'SNIPPET START, PROTOCOL 1 0\n', b'SNIPPET SERVING, PORT ABC\n'
+        ])
+    with self.assertRaisesRegex(errors.ServerStartProtocolError,
+                                'SNIPPET SERVING, PORT ABC'):
+      self.client.start_server()
+
+  @mock.patch('mobly.controllers.android_device_lib.snippet_client_v2.'
+              'utils.start_standing_subprocess')
+  def test_start_server_with_junk(self, mock_start_standing_subprocess):
+    """Tests that starting server process reports known protocol with junk."""
+    self._make_client()
+    self._mock_server_process_starting_response(
+        mock_start_standing_subprocess,
+        resp_lines=[
+            b'This is some header junk\n',
+            b'Some phones print arbitrary output\n',
+            b'SNIPPET START, PROTOCOL 1 0\n',
+            b'Maybe in the middle too\n',
+            b'SNIPPET SERVING, PORT 123\n',
+        ])
+    self.client.start_server()
+    self.assertEqual(123, self.client.device_port)
+
+  @mock.patch('mobly.controllers.android_device_lib.snippet_client_v2.'
+              'utils.start_standing_subprocess')
+  def test_start_server_no_valid_line(self, mock_start_standing_subprocess):
+    """Tests that starting server process reports unknown protocol message."""
+    self._make_client()
+    self._mock_server_process_starting_response(
+        mock_start_standing_subprocess,
+        resp_lines=[
+            b'This is some header junk\n',
+            b'Some phones print arbitrary output\n',
+            b'',  # readline uses '' to mark EOF
+        ])
+    with self.assertRaisesRegex(
+        errors.ServerStartError,
+        'Unexpected EOF when waiting for server to start.'):
+      self.client.start_server()
+
+  @mock.patch('mobly.utils.stop_standing_subprocess')
+  @mock.patch.object(mock_android_device.MockAdbProxy,
+                     'shell',
+                     return_value=b'OK (0 tests)')
+  def test_stop_server_normally(self, mock_android_device_shell,
+                                mock_stop_standing_subprocess):
+    """Tests that stopping server process works normally."""
+    self._make_client()
+    mock_proc = mock.Mock()
+    self.client._proc = mock_proc
+    self.client.stop()
+    self.assertIs(self.client._proc, None)
+    mock_android_device_shell.assert_called_once_with(
+        f'am instrument --user {MOCK_USER_ID} -w -e action stop '
+        f'{MOCK_SERVER_PATH}')
+    mock_stop_standing_subprocess.assert_called_once_with(mock_proc)
+
+  @mock.patch('mobly.utils.stop_standing_subprocess')
+  @mock.patch.object(mock_android_device.MockAdbProxy,
+                     'shell',
+                     return_value=b'OK (0 tests)')
+  def test_stop_server_server_already_cleaned(self, mock_android_device_shell,
+                                              mock_stop_standing_subprocess):
+    """Tests stopping server process when subprocess is already cleaned."""
+    self._make_client()
+    self.client._proc = None
+    self.client.stop()
+    self.assertIs(self.client._proc, None)
+    mock_stop_standing_subprocess.assert_not_called()
+    mock_android_device_shell.assert_called_once_with(
+        f'am instrument --user {MOCK_USER_ID} -w -e action stop '
+        f'{MOCK_SERVER_PATH}')
+
+  @mock.patch('mobly.utils.stop_standing_subprocess')
+  @mock.patch.object(mock_android_device.MockAdbProxy,
+                     'shell',
+                     return_value=b'Closed with error.')
+  def test_stop_server_stop_with_error(self, mock_android_device_shell,
+                                       mock_stop_standing_subprocess):
+    """Tests all resources are cleaned even if stopping server has error."""
+    self._make_client()
+    mock_proc = mock.Mock()
+    self.client._proc = mock_proc
+    with self.assertRaisesRegex(android_device_lib_errors.DeviceError,
+                                'Closed with error'):
+      self.client.stop()
+
+    self.assertIs(self.client._proc, None)
+    mock_stop_standing_subprocess.assert_called_once_with(mock_proc)
+    mock_android_device_shell.assert_called_once_with(
+        f'am instrument --user {MOCK_USER_ID} -w -e action stop '
+        f'{MOCK_SERVER_PATH}')
+
+
+if __name__ == '__main__':
+  unittest.main()
diff --git a/tests/mobly/controllers/android_device_test.py b/tests/mobly/controllers/android_device_test.py
index a98ecf3..6adb8f5 100755
--- a/tests/mobly/controllers/android_device_test.py
+++ b/tests/mobly/controllers/android_device_test.py
@@ -132,20 +132,26 @@
     with self.assertRaisesRegex(android_device.Error, expected_msg):
       android_device.create([1])
 
+  @mock.patch('mobly.controllers.android_device.list_fastboot_devices')
   @mock.patch('mobly.controllers.android_device.list_adb_devices')
   @mock.patch('mobly.controllers.android_device.list_adb_devices_by_usb_id')
   @mock.patch('mobly.controllers.android_device.AndroidDevice')
-  def test_get_instances(self, mock_ad_class, mock_list_adb_usb, mock_list_adb):
+  def test_get_instances(self, mock_ad_class, mock_list_adb_usb, mock_list_adb,
+                         mock_list_fastboot):
+    mock_list_fastboot.return_value = ['0']
     mock_list_adb.return_value = ['1']
     mock_list_adb_usb.return_value = []
-    android_device.get_instances(['1'])
-    mock_ad_class.assert_called_with('1')
+    android_device.get_instances(['0', '1'])
+    mock_ad_class.assert_any_call('0')
+    mock_ad_class.assert_any_call('1')
 
+  @mock.patch('mobly.controllers.android_device.list_fastboot_devices')
   @mock.patch('mobly.controllers.android_device.list_adb_devices')
   @mock.patch('mobly.controllers.android_device.list_adb_devices_by_usb_id')
   @mock.patch('mobly.controllers.android_device.AndroidDevice')
   def test_get_instances_do_not_exist(self, mock_ad_class, mock_list_adb_usb,
-                                      mock_list_adb):
+                                      mock_list_adb, mock_list_fastboot):
+    mock_list_fastboot.return_value = []
     mock_list_adb.return_value = []
     mock_list_adb_usb.return_value = []
     with self.assertRaisesRegex(
@@ -154,12 +160,14 @@
     ):
       android_device.get_instances(['1'])
 
+  @mock.patch('mobly.controllers.android_device.list_fastboot_devices')
   @mock.patch('mobly.controllers.android_device.list_adb_devices')
   @mock.patch('mobly.controllers.android_device.list_adb_devices_by_usb_id')
   @mock.patch('mobly.controllers.android_device.AndroidDevice')
   def test_get_instances_with_configs(self, mock_ad_class, mock_list_adb_usb,
-                                      mock_list_adb):
-    mock_list_adb.return_value = ['1', '2']
+                                      mock_list_adb, mock_list_fastboot):
+    mock_list_fastboot.return_value = ['1']
+    mock_list_adb.return_value = ['2']
     mock_list_adb_usb.return_value = []
     configs = [{'serial': '1'}, {'serial': '2'}]
     android_device.get_instances_with_configs(configs)
@@ -173,12 +181,15 @@
         f'Required value "serial" is missing in AndroidDevice config {config}'):
       android_device.get_instances_with_configs([config])
 
+  @mock.patch('mobly.controllers.android_device.list_fastboot_devices')
   @mock.patch('mobly.controllers.android_device.list_adb_devices')
   @mock.patch('mobly.controllers.android_device.list_adb_devices_by_usb_id')
   @mock.patch('mobly.controllers.android_device.AndroidDevice')
   def test_get_instances_with_configsdo_not_exist(self, mock_ad_class,
                                                   mock_list_adb_usb,
-                                                  mock_list_adb):
+                                                  mock_list_adb,
+                                                  mock_list_fastboot):
+    mock_list_fastboot.return_value = []
     mock_list_adb.return_value = []
     mock_list_adb_usb.return_value = []
     config = {'serial': '1'}
@@ -859,8 +870,8 @@
   @mock.patch('mobly.utils.create_dir')
   @mock.patch('mobly.logger.get_log_file_timestamp')
   def test_AndroidDevice_take_screenshot_with_prefix(
-    self, get_log_file_timestamp_mock, create_dir_mock,
-    FastbootProxy, MockAdbProxy):
+      self, get_log_file_timestamp_mock, create_dir_mock, FastbootProxy,
+      MockAdbProxy):
     get_log_file_timestamp_mock.return_value = '07-22-2019_17-53-34-450'
     mock_serial = '1'
     ad = android_device.AndroidDevice(serial=mock_serial)
@@ -1141,22 +1152,19 @@
     mock_serial = '1'
     ad = android_device.AndroidDevice(serial=mock_serial)
     self.assertEqual(ad.debug_tag, '1')
-    with self.assertRaisesRegex(
-        android_device.DeviceError,
-        r'<AndroidDevice\|1> Something'):
+    with self.assertRaisesRegex(android_device.DeviceError,
+                                r'<AndroidDevice\|1> Something'):
       raise android_device.DeviceError(ad, 'Something')
 
     # Verify that debug tag's setter updates the debug prefix correctly.
     ad.debug_tag = 'Mememe'
-    with self.assertRaisesRegex(
-        android_device.DeviceError,
-        r'<AndroidDevice\|Mememe> Something'):
+    with self.assertRaisesRegex(android_device.DeviceError,
+                                r'<AndroidDevice\|Mememe> Something'):
       raise android_device.DeviceError(ad, 'Something')
 
     # Verify that repr is changed correctly.
-    with self.assertRaisesRegex(
-        Exception,
-        r'(<AndroidDevice\|Mememe>, \'Something\')'):
+    with self.assertRaisesRegex(Exception,
+                                r'(<AndroidDevice\|Mememe>, \'Something\')'):
       raise Exception(ad, 'Something')
 
   @mock.patch('mobly.controllers.android_device_lib.adb.AdbProxy',
diff --git a/tests/mobly/snippet/__init__.py b/tests/mobly/snippet/__init__.py
new file mode 100644
index 0000000..ac3f9e6
--- /dev/null
+++ b/tests/mobly/snippet/__init__.py
@@ -0,0 +1,13 @@
+# Copyright 2022 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.
diff --git a/tests/mobly/snippet/client_base_test.py b/tests/mobly/snippet/client_base_test.py
new file mode 100755
index 0000000..d9d99bd
--- /dev/null
+++ b/tests/mobly/snippet/client_base_test.py
@@ -0,0 +1,424 @@
+# Copyright 2022 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.
+"""Unit tests for mobly.snippet.client_base."""
+
+import logging
+import random
+import string
+import unittest
+from unittest import mock
+
+from mobly.snippet import client_base
+from mobly.snippet import errors
+
+
+def _generate_fix_length_rpc_response(
+    response_length,
+    template='{"id": 0, "result": "%s", "error": null, "callback": null}'):
+  """Generates an RPC response string with specified length.
+
+  This function generates a random string and formats the template with the
+  generated random string to get the response string. This function formats
+  the template with printf style string formatting.
+
+  Args:
+    response_length: int, the length of the response string to generate.
+    template: str, the template used for generating the response string.
+
+  Returns:
+    The generated response string.
+
+  Raises:
+    ValueError: if the specified length is too small to generate a response.
+  """
+  # We need to -2 here because the string formatting will delete the substring
+  # '%s' in the template, of which the length is 2.
+  result_length = response_length - (len(template) - 2)
+  if result_length < 0:
+    raise ValueError(f'The response_length should be no smaller than '
+                     f'template_length + 2. Got response_length '
+                     f'{response_length}, template_length {len(template)}.')
+  chars = string.ascii_letters + string.digits
+  return template % ''.join(random.choice(chars) for _ in range(result_length))
+
+
+class FakeClient(client_base.ClientBase):
+  """Fake client class for unit tests."""
+
+  def __init__(self):
+    """Initializes the instance by mocking a device controller."""
+    mock_device = mock.Mock()
+    mock_device.log = logging
+    super().__init__(package='FakeClient', device=mock_device)
+
+  # Override abstract methods to enable initialization
+  def before_starting_server(self):
+    pass
+
+  def start_server(self):
+    pass
+
+  def make_connection(self):
+    pass
+
+  def restore_server_connection(self, port=None):
+    pass
+
+  def check_server_proc_running(self):
+    pass
+
+  def send_rpc_request(self, request):
+    pass
+
+  def handle_callback(self, callback_id, ret_value, rpc_func_name):
+    pass
+
+  def stop(self):
+    pass
+
+  def close_connection(self):
+    pass
+
+
+class ClientBaseTest(unittest.TestCase):
+  """Unit tests for mobly.snippet.client_base.ClientBase."""
+
+  def setUp(self):
+    super().setUp()
+    self.client = FakeClient()
+    self.client.host_port = 12345
+
+  @mock.patch.object(FakeClient, 'before_starting_server')
+  @mock.patch.object(FakeClient, 'start_server')
+  @mock.patch.object(FakeClient, '_make_connection')
+  def test_init_server_stage_order(self, mock_make_conn_func, mock_start_func,
+                                   mock_before_func):
+    """Test that initialization runs its stages in expected order."""
+    order_manager = mock.Mock()
+    order_manager.attach_mock(mock_before_func, 'mock_before_func')
+    order_manager.attach_mock(mock_start_func, 'mock_start_func')
+    order_manager.attach_mock(mock_make_conn_func, 'mock_make_conn_func')
+
+    self.client.initialize()
+
+    expected_call_order = [
+        mock.call.mock_before_func(),
+        mock.call.mock_start_func(),
+        mock.call.mock_make_conn_func(),
+    ]
+    self.assertListEqual(order_manager.mock_calls, expected_call_order)
+
+  @mock.patch.object(FakeClient, 'stop')
+  @mock.patch.object(FakeClient, 'before_starting_server')
+  def test_init_server_before_starting_server_fail(self, mock_before_func,
+                                                   mock_stop_func):
+    """Test before_starting_server stage of initialization fails."""
+    mock_before_func.side_effect = Exception('ha')
+
+    with self.assertRaisesRegex(Exception, 'ha'):
+      self.client.initialize()
+    mock_stop_func.assert_not_called()
+
+  @mock.patch.object(FakeClient, 'stop')
+  @mock.patch.object(FakeClient, 'start_server')
+  def test_init_server_start_server_fail(self, mock_start_func, mock_stop_func):
+    """Test start_server stage of initialization fails."""
+    mock_start_func.side_effect = Exception('ha')
+
+    with self.assertRaisesRegex(Exception, 'ha'):
+      self.client.initialize()
+    mock_stop_func.assert_called()
+
+  @mock.patch.object(FakeClient, 'stop')
+  @mock.patch.object(FakeClient, '_make_connection')
+  def test_init_server_make_connection_fail(self, mock_make_conn_func,
+                                            mock_stop_func):
+    """Test _make_connection stage of initialization fails."""
+    mock_make_conn_func.side_effect = Exception('ha')
+
+    with self.assertRaisesRegex(Exception, 'ha'):
+      self.client.initialize()
+    mock_stop_func.assert_called()
+
+  @mock.patch.object(FakeClient, 'check_server_proc_running')
+  @mock.patch.object(FakeClient, '_gen_rpc_request')
+  @mock.patch.object(FakeClient, 'send_rpc_request')
+  @mock.patch.object(FakeClient, '_decode_response_string_and_validate_format')
+  @mock.patch.object(FakeClient, '_handle_rpc_response')
+  def test_rpc_stage_dependencies(self, mock_handle_resp, mock_decode_resp_str,
+                                  mock_send_request, mock_gen_request,
+                                  mock_precheck):
+    """Test the internal dependencies when sending an RPC.
+
+    When sending an RPC, it calls multiple functions in specific order, and
+    each function uses the output of the previously called function. This test
+    case checks above dependencies.
+
+    Args:
+      mock_handle_resp: the mock function of FakeClient._handle_rpc_response.
+      mock_decode_resp_str: the mock function of
+        FakeClient._decode_response_string_and_validate_format.
+      mock_send_request: the mock function of FakeClient.send_rpc_request.
+      mock_gen_request: the mock function of FakeClient._gen_rpc_request.
+      mock_precheck: the mock function of FakeClient.check_server_proc_running.
+    """
+    self.client.initialize()
+
+    expected_response_str = ('{"id": 0, "result": 123, "error": null, '
+                             '"callback": null}')
+    expected_response_dict = {
+        'id': 0,
+        'result': 123,
+        'error': None,
+        'callback': None,
+    }
+    expected_request = ('{"id": 10, "method": "some_rpc", "params": [1, 2],'
+                        '"kwargs": {"test_key": 3}')
+    expected_result = 123
+
+    mock_gen_request.return_value = expected_request
+    mock_send_request.return_value = expected_response_str
+    mock_decode_resp_str.return_value = expected_response_dict
+    mock_handle_resp.return_value = expected_result
+    rpc_result = self.client.some_rpc(1, 2, test_key=3)
+
+    mock_precheck.assert_called()
+    mock_gen_request.assert_called_with(0, 'some_rpc', 1, 2, test_key=3)
+    mock_send_request.assert_called_with(expected_request)
+    mock_decode_resp_str.assert_called_with(0, expected_response_str)
+    mock_handle_resp.assert_called_with('some_rpc', expected_response_dict)
+    self.assertEqual(rpc_result, expected_result)
+
+  @mock.patch.object(FakeClient, 'check_server_proc_running')
+  @mock.patch.object(FakeClient, '_gen_rpc_request')
+  @mock.patch.object(FakeClient, 'send_rpc_request')
+  @mock.patch.object(FakeClient, '_decode_response_string_and_validate_format')
+  @mock.patch.object(FakeClient, '_handle_rpc_response')
+  def test_rpc_precheck_fail(self, mock_handle_resp, mock_decode_resp_str,
+                             mock_send_request, mock_gen_request,
+                             mock_precheck):
+    """Test when RPC precheck fails it will skip sending the RPC."""
+    self.client.initialize()
+    mock_precheck.side_effect = Exception('server_died')
+
+    with self.assertRaisesRegex(Exception, 'server_died'):
+      self.client.some_rpc(1, 2)
+
+    mock_gen_request.assert_not_called()
+    mock_send_request.assert_not_called()
+    mock_handle_resp.assert_not_called()
+    mock_decode_resp_str.assert_not_called()
+
+  def test_gen_request(self):
+    """Test generating an RPC request.
+
+    Test that _gen_rpc_request returns a string represents a JSON dict
+    with all required fields.
+    """
+    request = self.client._gen_rpc_request(0, 'test_rpc', 1, 2, test_key=3)
+    expected_result = ('{"id": 0, "kwargs": {"test_key": 3}, '
+                       '"method": "test_rpc", "params": [1, 2]}')
+    self.assertEqual(request, expected_result)
+
+  def test_gen_request_without_kwargs(self):
+    """Test no keyword arguments.
+
+    Test that _gen_rpc_request ignores the kwargs field when no
+    keyword arguments.
+    """
+    request = self.client._gen_rpc_request(0, 'test_rpc', 1, 2)
+    expected_result = '{"id": 0, "method": "test_rpc", "params": [1, 2]}'
+    self.assertEqual(request, expected_result)
+
+  def test_rpc_no_response(self):
+    """Test parsing an empty RPC response."""
+    with self.assertRaisesRegex(errors.ProtocolError,
+                                errors.ProtocolError.NO_RESPONSE_FROM_SERVER):
+      self.client._decode_response_string_and_validate_format(0, '')
+
+    with self.assertRaisesRegex(errors.ProtocolError,
+                                errors.ProtocolError.NO_RESPONSE_FROM_SERVER):
+      self.client._decode_response_string_and_validate_format(0, None)
+
+  def test_rpc_response_missing_fields(self):
+    """Test parsing an RPC response that misses some required fields."""
+    mock_resp_without_id = '{"result": 123, "error": null, "callback": null}'
+    with self.assertRaisesRegex(
+        errors.ProtocolError,
+        errors.ProtocolError.RESPONSE_MISSING_FIELD % 'id'):
+      self.client._decode_response_string_and_validate_format(
+          10, mock_resp_without_id)
+
+    mock_resp_without_result = '{"id": 10, "error": null, "callback": null}'
+    with self.assertRaisesRegex(
+        errors.ProtocolError,
+        errors.ProtocolError.RESPONSE_MISSING_FIELD % 'result'):
+      self.client._decode_response_string_and_validate_format(
+          10, mock_resp_without_result)
+
+    mock_resp_without_error = '{"id": 10, "result": 123, "callback": null}'
+    with self.assertRaisesRegex(
+        errors.ProtocolError,
+        errors.ProtocolError.RESPONSE_MISSING_FIELD % 'error'):
+      self.client._decode_response_string_and_validate_format(
+          10, mock_resp_without_error)
+
+    mock_resp_without_callback = '{"id": 10, "result": 123, "error": null}'
+    with self.assertRaisesRegex(
+        errors.ProtocolError,
+        errors.ProtocolError.RESPONSE_MISSING_FIELD % 'callback'):
+      self.client._decode_response_string_and_validate_format(
+          10, mock_resp_without_callback)
+
+  def test_rpc_response_error(self):
+    """Test parsing an RPC response with a non-empty error field."""
+    mock_resp_with_error = {
+        'id': 10,
+        'result': 123,
+        'error': 'some_error',
+        'callback': None,
+    }
+    with self.assertRaisesRegex(errors.ApiError, 'some_error'):
+      self.client._handle_rpc_response('some_rpc', mock_resp_with_error)
+
+  def test_rpc_response_callback(self):
+    """Test parsing response function handles the callback field well."""
+    # Call handle_callback function if the "callback" field is not None
+    mock_resp_with_callback = {
+        'id': 10,
+        'result': 123,
+        'error': None,
+        'callback': '1-0'
+    }
+    with mock.patch.object(self.client,
+                           'handle_callback') as mock_handle_callback:
+      expected_callback = mock.Mock()
+      mock_handle_callback.return_value = expected_callback
+
+      rpc_result = self.client._handle_rpc_response('some_rpc',
+                                                    mock_resp_with_callback)
+      mock_handle_callback.assert_called_with('1-0', 123, 'some_rpc')
+      # Ensure the RPC function returns what handle_callback returned
+      self.assertIs(expected_callback, rpc_result)
+
+    # Do not call handle_callback function if the "callback" field is None
+    mock_resp_without_callback = {
+        'id': 10,
+        'result': 123,
+        'error': None,
+        'callback': None
+    }
+    with mock.patch.object(self.client,
+                           'handle_callback') as mock_handle_callback:
+      self.client._handle_rpc_response('some_rpc', mock_resp_without_callback)
+      mock_handle_callback.assert_not_called()
+
+  def test_rpc_response_id_mismatch(self):
+    """Test parsing an RPC response with a wrong id."""
+    right_id = 5
+    wrong_id = 20
+    resp = f'{{"id": {right_id}, "result": 1, "error": null, "callback": null}}'
+
+    with self.assertRaisesRegex(errors.ProtocolError,
+                                errors.ProtocolError.MISMATCHED_API_ID):
+      self.client._decode_response_string_and_validate_format(wrong_id, resp)
+
+  @mock.patch.object(FakeClient, 'send_rpc_request')
+  def test_rpc_verbose_logging_with_long_string(self, mock_send_request):
+    """Test RPC response isn't truncated when verbose logging is on."""
+    mock_log = mock.Mock()
+    self.client.log = mock_log
+    self.client.set_snippet_client_verbose_logging(True)
+    self.client.initialize()
+
+    resp = _generate_fix_length_rpc_response(
+        client_base._MAX_RPC_RESP_LOGGING_LENGTH * 2)
+    mock_send_request.return_value = resp
+    self.client.some_rpc(1, 2)
+    mock_log.debug.assert_called_with('Snippet received: %s', resp)
+
+  @mock.patch.object(FakeClient, 'send_rpc_request')
+  def test_rpc_truncated_logging_short_response(self, mock_send_request):
+    """Test RPC response isn't truncated with small length."""
+    mock_log = mock.Mock()
+    self.client.log = mock_log
+    self.client.set_snippet_client_verbose_logging(False)
+    self.client.initialize()
+
+    resp = _generate_fix_length_rpc_response(
+        int(client_base._MAX_RPC_RESP_LOGGING_LENGTH // 2))
+    mock_send_request.return_value = resp
+    self.client.some_rpc(1, 2)
+    mock_log.debug.assert_called_with('Snippet received: %s', resp)
+
+  @mock.patch.object(FakeClient, 'send_rpc_request')
+  def test_rpc_truncated_logging_fit_size_response(self, mock_send_request):
+    """Test RPC response isn't truncated with length equal to the threshold."""
+    mock_log = mock.Mock()
+    self.client.log = mock_log
+    self.client.set_snippet_client_verbose_logging(False)
+    self.client.initialize()
+
+    resp = _generate_fix_length_rpc_response(
+        client_base._MAX_RPC_RESP_LOGGING_LENGTH)
+    mock_send_request.return_value = resp
+    self.client.some_rpc(1, 2)
+    mock_log.debug.assert_called_with('Snippet received: %s', resp)
+
+  @mock.patch.object(FakeClient, 'send_rpc_request')
+  def test_rpc_truncated_logging_long_response(self, mock_send_request):
+    """Test RPC response is truncated with length larger than the threshold."""
+    mock_log = mock.Mock()
+    self.client.log = mock_log
+    self.client.set_snippet_client_verbose_logging(False)
+    self.client.initialize()
+
+    max_len = client_base._MAX_RPC_RESP_LOGGING_LENGTH
+    resp = _generate_fix_length_rpc_response(max_len * 40)
+    mock_send_request.return_value = resp
+    self.client.some_rpc(1, 2)
+    mock_log.debug.assert_called_with(
+        'Snippet received: %s... %d chars are truncated',
+        resp[:client_base._MAX_RPC_RESP_LOGGING_LENGTH],
+        len(resp) - max_len)
+
+  @mock.patch.object(FakeClient, 'send_rpc_request')
+  def test_rpc_call_increment_counter(self, mock_send_request):
+    """Test that with each RPC call the counter is incremented by 1."""
+    self.client.initialize()
+    resp = '{"id": %d, "result": 123, "error": null, "callback": null}'
+    mock_send_request.side_effect = (resp % (i,) for i in range(10))
+
+    for _ in range(0, 10):
+      self.client.some_rpc()
+
+    self.assertEqual(next(self.client._counter), 10)
+
+  @mock.patch.object(FakeClient, 'send_rpc_request')
+  def test_init_connection_reset_counter(self, mock_send_request):
+    """Test that _make_connection resets the counter to zero."""
+    self.client.initialize()
+    resp = '{"id": %d, "result": 123, "error": null, "callback": null}'
+    mock_send_request.side_effect = (resp % (i,) for i in range(10))
+
+    for _ in range(0, 10):
+      self.client.some_rpc()
+
+    self.assertEqual(next(self.client._counter), 10)
+    self.client._make_connection()
+    self.assertEqual(next(self.client._counter), 0)
+
+
+if __name__ == '__main__':
+  unittest.main()