Merge upstream into main

Updating Mobly to the latest upstream involves the following sequence of
CLs:

1. Add new file: logcat_processor.py (not actually necessary, it turns
   out, but too late!)
2. Update mobly to the latest revision [THIS CL]
3. Add logcat_processor.py to and remove snippet_client.py from
   the build rules, as well as updating the jiri manifest.

Change-Id: I44d02934417a118eac1fa4348974d6a6686df840
Reviewed-on: https://fuchsia-review.googlesource.com/c/third_party/github.com/google/mobly/+/1835549
Reviewed-by: Pavan Kumar Juturu <jpavankumar@google.com>
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 414f107..c0e1149 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,19 +2,22 @@
 
 on: [push, pull_request]
 
+permissions:
+  contents: read
+
 jobs:
   build-and-test:
     runs-on: ${{ matrix.os }}
     strategy:
       matrix:
         os: [ubuntu-latest, macos-latest, windows-latest]
-        python-version: ["3.11", "3.12"]
+        python-version: ["3.11", "3.12", "3.13", "3.14"]
     steps:
     - name: Checkout repo
-      uses: actions/checkout@v6
+      uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
 
     - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v6
+      uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
       with:
         python-version: ${{ matrix.python-version }}
 
@@ -31,4 +34,3 @@
       run: |
         python -m pip install pyink==24.3.0
         pyink --check .
-
diff --git a/.gitignore b/.gitignore
index 06591ae..36941e0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,6 @@
 _site
 .tox/
 venv/
+docs/_build/
+docs/mobly*.rst
+docs/modules.rst
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 0000000..db61d18
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,19 @@
+# .readthedocs.yaml
+# Read the Docs configuration file
+# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
+
+version: 2
+
+build:
+  os: ubuntu-24.04
+  tools:
+    python: "3.11"
+
+sphinx:
+  configuration: docs/conf.py
+
+python:
+  install:
+    - requirements: docs/requirements.txt
+    - method: pip
+      path: .
diff --git a/.readthedocs.yml b/.readthedocs.yml
deleted file mode 100644
index c52a552..0000000
--- a/.readthedocs.yml
+++ /dev/null
@@ -1,25 +0,0 @@
-# .readthedocs.yml
-# Read the Docs configuration file
-# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
-
-# Required
-version: 2
-
-# Build documentation in the docs/ directory with Sphinx
-sphinx:
-  builder: html
-  configuration: docs/conf.py
-
-# Build documentation with MkDocs
-#mkdocs:
-#  configuration: mkdocs.yml
-
-# Optionally build your docs in additional formats such as PDF and ePub
-formats: all
-
-# Optionally set the version of Python and requirements required to build your docs
-python:
-  version: 3.7
-  install:
-    - requirements: docs/requirements.txt
-
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b6f4b4a..264aa61 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,26 @@
 # Mobly Release History
 
-# Mobly Release 1.13: SL4A Removal and Test Suite Improvements
+## Mobly Release 1.13.1: Minor Improvements and Bug Fixes
+
+Maintenance release with improvements to Android device controllers and snippets, and various bug fixes.
+
+### New
+* Support multi-display screenshot capture in `AndroidDevice`.
+* Add `enable_runtime_perms` flag to APK installation helper.
+* Support loading multiple snippet instances with the same package across different user IDs.
+* Support custom timeout for `adb root` in `AndroidDevice`.
+* Support custom error messages in snippet `waitForEvent`.
+
+### Fixes
+* Fix logcat pause/resume to reliably capture logs across suspended periods.
+* Maintain consistent newline formatting in logcat excerpts.
+* Avoid redundant adb port forwarding teardown when port is not forwarded in snippet client.
+* Fix logging timing of snippet RPC requests.
+
+[Full list of changes](https://github.com/google/mobly/milestone/33?closed=1)
+
+
+## Mobly Release 1.13: SL4A Removal and Test Suite Improvements
 Removed all SL4A related code. Improved test suite mechanism.
 
 ### New
diff --git a/docs/conf.py b/docs/conf.py
index 2a7e6a9..43680a6 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -19,6 +19,8 @@
 import os
 import sys
 
+from sphinx.ext import apidoc
+
 sys.path.insert(0, os.path.abspath(os.path.pardir))
 
 # -- General configuration ------------------------------------------------
@@ -32,43 +34,64 @@
 # ones.
 extensions = [
     'sphinx.ext.autodoc',
+    'sphinx.ext.intersphinx',
     'sphinx.ext.napoleon',
     'sphinx.ext.todo',
     'sphinx.ext.viewcode',
+    'sphinx_autodoc_typehints',
+    'myst_parser',
+]
+
+# Intersphinx mapping to standard libraries
+intersphinx_mapping = {
+    'python': ('https://docs.python.org/3', None),
+}
+
+# MyST Parser configuration
+myst_enable_extensions = [
+    'colon_fence',
+    'deflist',
+    'fieldlist',
+    'tasklist',
 ]
 
 # Add any paths that contain templates here, relative to this directory.
 templates_path = ['_templates']
 
-# Setting up parsers for MD support
-from recommonmark.parser import CommonMarkParser
-
-source_parsers = {
-    '.md': CommonMarkParser,
-}
-
 # The suffix(es) of source filenames.
 # You can specify multiple suffix as a list of string:
-#
 source_suffix = ['.rst', '.md']
-# source_suffix = '.rst'
 
 # The master toctree document.
 master_doc = 'index'
 
+from importlib.metadata import PackageNotFoundError
+from importlib.metadata import version as get_version
+
 # General information about the project.
 project = 'Mobly'
 copyright = 'Copyright 2016 Google Inc.'
 author = 'Ang Li'
 
-# The version info for the project you're documenting, acts as replacement for
-# |version| and |release|, also used in various other places throughout the
-# built documents.
-#
-# The short X.Y version.
-version = ''
-# The full version, including alpha/beta/rc tags.
-release = ''
+try:
+  release: str = get_version('mobly')
+  version: str = '.'.join(release.split('.')[:2])
+except PackageNotFoundError:
+  version = '1.13'
+  release = '1.13.1'
+
+# Napoleon settings for Google-style docstrings
+napoleon_google_docstring = True
+napoleon_numpy_docstring = False
+napoleon_include_init_with_doc = True
+napoleon_include_private_with_doc = False
+napoleon_include_special_with_doc = True
+napoleon_use_admonition_for_examples = True
+napoleon_use_admonition_for_notes = True
+napoleon_use_admonition_for_warnings = True
+napoleon_use_param = True
+napoleon_use_rtype = True
+napoleon_preprocess_types = True
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
@@ -84,9 +107,6 @@
     '_build',
     'Thumbs.db',
     '.DS_Store',
-    'tutorial.md',
-    'instrumentation_tutorial.md',
-    'android_device_service.md',
 ]
 
 # The name of the Pygments (syntax highlighting) style to use.
@@ -100,7 +120,7 @@
 # The theme to use for HTML and HTML Help pages.  See the documentation for
 # a list of builtin themes.
 #
-html_theme = 'alabaster'
+html_theme = 'furo'
 
 # Theme options are theme-specific and customize the look and feel of a theme
 # further.  For a list of options available for each theme, see the
@@ -188,3 +208,32 @@
 
 # A list of files that should not be packed into the epub file.
 epub_exclude_files = ['search.html']
+
+
+def run_apidoc(_):
+  """Automatically runs sphinx-apidoc on Sphinx builder initialization."""
+  cur_dir = os.path.abspath(os.path.dirname(__file__))
+  module_dir = os.path.abspath(os.path.join(cur_dir, os.path.pardir, 'mobly'))
+  output_dir = cur_dir
+
+  excludes = [
+      os.path.join(module_dir, '*_test.py'),
+      os.path.join(module_dir, '**', '*_test.py'),
+  ]
+
+  apidoc.main(
+      [
+          '--force',
+          '--separate',
+          '--module-first',
+          '--no-toc',
+          '-o',
+          output_dir,
+          module_dir,
+      ]
+      + excludes
+  )
+
+
+def setup(app):
+  app.connect('builder-inited', run_apidoc)
diff --git a/docs/index.rst b/docs/index.rst
index 808397b..05afc93 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -7,11 +7,19 @@
 =================================
 
 .. toctree::
+   :maxdepth: 2
+   :caption: User Guides & Tutorials:
+
+   tutorial
+   instrumentation_tutorial
+   android_device_service
+   tutorial_custom_controller
+
+.. toctree::
    :maxdepth: 4
-   :caption: Contents:
+   :caption: API Reference:
 
    mobly
-   tutorial_custom_controller
 
 Indices and tables
 ==================
diff --git a/docs/mobly.controllers.android_device_lib.rst b/docs/mobly.controllers.android_device_lib.rst
deleted file mode 100644
index 8f1d379..0000000
--- a/docs/mobly.controllers.android_device_lib.rst
+++ /dev/null
@@ -1,109 +0,0 @@
-mobly.controllers.android\_device\_lib package
-==============================================
-
-Subpackages
------------
-
-.. toctree::
-
-   mobly.controllers.android_device_lib.services
-
-Submodules
-----------
-
-mobly.controllers.android\_device\_lib.adb module
--------------------------------------------------
-
-.. automodule:: mobly.controllers.android_device_lib.adb
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.controllers.android\_device\_lib.callback\_handler module
----------------------------------------------------------------
-
-.. automodule:: mobly.controllers.android_device_lib.callback_handler
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.controllers.android\_device\_lib.errors module
-----------------------------------------------------
-
-.. automodule:: mobly.controllers.android_device_lib.errors
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.controllers.android\_device\_lib.event\_dispatcher module
----------------------------------------------------------------
-
-.. automodule:: mobly.controllers.android_device_lib.event_dispatcher
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.controllers.android\_device\_lib.fastboot module
-------------------------------------------------------
-
-.. automodule:: mobly.controllers.android_device_lib.fastboot
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.controllers.android\_device\_lib.jsonrpc\_client\_base module
--------------------------------------------------------------------
-
-.. automodule:: mobly.controllers.android_device_lib.jsonrpc_client_base
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.controllers.android\_device\_lib.jsonrpc\_shell\_base module
-------------------------------------------------------------------
-
-.. automodule:: mobly.controllers.android_device_lib.jsonrpc_shell_base
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.controllers.android\_device\_lib.service\_manager module
---------------------------------------------------------------
-
-.. automodule:: mobly.controllers.android_device_lib.service_manager
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.controllers.android\_device\_lib.sl4a\_client module
-----------------------------------------------------------
-
-.. automodule:: mobly.controllers.android_device_lib.sl4a_client
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.controllers.android\_device\_lib.snippet\_client module
--------------------------------------------------------------
-
-.. automodule:: mobly.controllers.android_device_lib.snippet_client
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.controllers.android\_device\_lib.snippet\_event module
-------------------------------------------------------------
-
-.. automodule:: mobly.controllers.android_device_lib.snippet_event
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-
-Module contents
----------------
-
-.. automodule:: mobly.controllers.android_device_lib
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/docs/mobly.controllers.android_device_lib.services.rst b/docs/mobly.controllers.android_device_lib.services.rst
deleted file mode 100644
index 2789e8a..0000000
--- a/docs/mobly.controllers.android_device_lib.services.rst
+++ /dev/null
@@ -1,46 +0,0 @@
-mobly.controllers.android\_device\_lib.services package
-=======================================================
-
-Submodules
-----------
-
-mobly.controllers.android\_device\_lib.services.base\_service module
---------------------------------------------------------------------
-
-.. automodule:: mobly.controllers.android_device_lib.services.base_service
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.controllers.android\_device\_lib.services.logcat module
--------------------------------------------------------------
-
-.. automodule:: mobly.controllers.android_device_lib.services.logcat
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.controllers.android\_device\_lib.services.sl4a\_service module
---------------------------------------------------------------------
-
-.. automodule:: mobly.controllers.android_device_lib.services.sl4a_service
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.controllers.android\_device\_lib.services.snippet\_management\_service module
------------------------------------------------------------------------------------
-
-.. automodule:: mobly.controllers.android_device_lib.services.snippet_management_service
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-
-Module contents
----------------
-
-.. automodule:: mobly.controllers.android_device_lib.services
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/docs/mobly.controllers.attenuator_lib.rst b/docs/mobly.controllers.attenuator_lib.rst
deleted file mode 100644
index a395456..0000000
--- a/docs/mobly.controllers.attenuator_lib.rst
+++ /dev/null
@@ -1,30 +0,0 @@
-mobly.controllers.attenuator\_lib package
-=========================================
-
-Submodules
-----------
-
-mobly.controllers.attenuator\_lib.minicircuits module
------------------------------------------------------
-
-.. automodule:: mobly.controllers.attenuator_lib.minicircuits
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.controllers.attenuator\_lib.telnet\_scpi\_client module
--------------------------------------------------------------
-
-.. automodule:: mobly.controllers.attenuator_lib.telnet_scpi_client
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-
-Module contents
----------------
-
-.. automodule:: mobly.controllers.attenuator_lib
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/docs/mobly.controllers.rst b/docs/mobly.controllers.rst
deleted file mode 100644
index 17de06d..0000000
--- a/docs/mobly.controllers.rst
+++ /dev/null
@@ -1,63 +0,0 @@
-mobly.controllers package
-=========================
-
-Subpackages
------------
-
-.. toctree::
-
-   mobly.controllers.android_device_lib
-   mobly.controllers.attenuator_lib
-   mobly.controllers.sniffer_lib
-
-Submodules
-----------
-
-mobly.controllers.android\_device module
-----------------------------------------
-
-.. automodule:: mobly.controllers.android_device
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.controllers.attenuator module
------------------------------------
-
-.. automodule:: mobly.controllers.attenuator
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.controllers.iperf\_server module
---------------------------------------
-
-.. automodule:: mobly.controllers.iperf_server
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.controllers.monsoon module
---------------------------------
-
-.. automodule:: mobly.controllers.monsoon
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.controllers.sniffer module
---------------------------------
-
-.. automodule:: mobly.controllers.sniffer
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-
-Module contents
----------------
-
-.. automodule:: mobly.controllers
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/docs/mobly.controllers.sniffer_lib.local.rst b/docs/mobly.controllers.sniffer_lib.local.rst
deleted file mode 100644
index 1924f9a..0000000
--- a/docs/mobly.controllers.sniffer_lib.local.rst
+++ /dev/null
@@ -1,38 +0,0 @@
-mobly.controllers.sniffer\_lib.local package
-============================================
-
-Submodules
-----------
-
-mobly.controllers.sniffer\_lib.local.local\_base module
--------------------------------------------------------
-
-.. automodule:: mobly.controllers.sniffer_lib.local.local_base
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.controllers.sniffer\_lib.local.tcpdump module
----------------------------------------------------
-
-.. automodule:: mobly.controllers.sniffer_lib.local.tcpdump
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.controllers.sniffer\_lib.local.tshark module
---------------------------------------------------
-
-.. automodule:: mobly.controllers.sniffer_lib.local.tshark
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-
-Module contents
----------------
-
-.. automodule:: mobly.controllers.sniffer_lib.local
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/docs/mobly.controllers.sniffer_lib.rst b/docs/mobly.controllers.sniffer_lib.rst
deleted file mode 100644
index c3b088d..0000000
--- a/docs/mobly.controllers.sniffer_lib.rst
+++ /dev/null
@@ -1,17 +0,0 @@
-mobly.controllers.sniffer\_lib package
-======================================
-
-Subpackages
------------
-
-.. toctree::
-
-   mobly.controllers.sniffer_lib.local
-
-Module contents
----------------
-
-.. automodule:: mobly.controllers.sniffer_lib
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/docs/mobly.rst b/docs/mobly.rst
deleted file mode 100644
index d3e0961..0000000
--- a/docs/mobly.rst
+++ /dev/null
@@ -1,133 +0,0 @@
-mobly package
-=============
-
-Subpackages
------------
-
-.. toctree::
-
-   mobly.controllers
-
-Submodules
-----------
-
-mobly.asserts module
---------------------
-
-.. automodule:: mobly.asserts
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.base\_instrumentation\_test module
-----------------------------------------
-
-.. automodule:: mobly.base_instrumentation_test
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.base\_test module
------------------------
-
-.. automodule:: mobly.base_test
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.config\_parser module
----------------------------
-
-.. automodule:: mobly.config_parser
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.controller\_manager module
---------------------------------
-
-.. automodule:: mobly.controller_manager
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.expects module
---------------------
-
-.. automodule:: mobly.expects
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.keys module
------------------
-
-.. automodule:: mobly.keys
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.logger module
--------------------
-
-.. automodule:: mobly.logger
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.records module
---------------------
-
-.. automodule:: mobly.records
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.runtime\_test\_info module
---------------------------------
-
-.. automodule:: mobly.runtime_test_info
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.signals module
---------------------
-
-.. automodule:: mobly.signals
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.suite\_runner module
---------------------------
-
-.. automodule:: mobly.suite_runner
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.test\_runner module
--------------------------
-
-.. automodule:: mobly.test_runner
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-mobly.utils module
-------------------
-
-.. automodule:: mobly.utils
-   :members:
-   :undoc-members:
-   :show-inheritance:
-
-
-Module contents
----------------
-
-.. automodule:: mobly
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/docs/requirements.txt b/docs/requirements.txt
index d9f0263..b479cad 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,3 +1,5 @@
-recommonmark
+furo
+myst-parser
 sphinx
+sphinx-autodoc-typehints
 mobly
\ No newline at end of file
diff --git a/mobly/base_test.py b/mobly/base_test.py
index e4ac9a0..3f2a6cb 100644
--- a/mobly/base_test.py
+++ b/mobly/base_test.py
@@ -34,8 +34,8 @@
 RESULT_LINE_TEMPLATE = TEST_CASE_TOKEN + ' %s %s'
 TEST_SELECTOR_REGEX_PREFIX = 're:'
 
-TEST_STAGE_BEGIN_LOG_TEMPLATE = '[{parent_token}]#{child_token} >>> BEGIN >>>'
-TEST_STAGE_END_LOG_TEMPLATE = '[{parent_token}]#{child_token} <<< END <<<'
+TEST_STAGE_BEGIN_LOG_TEMPLATE = '[%s]#%s >>> BEGIN >>>'
+TEST_STAGE_END_LOG_TEMPLATE = '[%s]#%s <<< END <<<'
 
 # Names of execution stages, in the order they happen during test runs.
 STAGE_NAME_PRE_RUN = 'pre_run'
@@ -47,34 +47,39 @@
 
 # Attribute names
 ATTR_REPEAT_CNT = '_repeat_count'
+ATTR_REPEAT_CNT_KEY = '_repeat_count_key'
 ATTR_MAX_RETRY_CNT = '_max_retry_count'
+ATTR_MAX_RETRY_CNT_KEY = '_max_retry_count_key'
 ATTR_MAX_CONSEC_ERROR = '_max_consecutive_error'
+ATTR_MAX_CONSEC_ERROR_KEY = '_max_consecutive_error_key'
 
 
 class Error(Exception):
   """Raised for exceptions that occurred in BaseTestClass."""
 
 
-def repeat(count, max_consecutive_error=None):
+def repeat(
+    count: int,
+    max_consecutive_error: int | None = None,
+    *,
+    count_key: str | None = None,
+    max_consecutive_error_key: str | None = None,
+):
   """Decorator for repeating a test case multiple times.
 
   The BaseTestClass will execute the test cases annotated with this decorator
-  the specified number of time.
-
-  This decorator only stores the information needed for the repeat. It does not
-  execute the repeat.
+  the specified number of times.
 
   Args:
-    count: int, the total number of times to execute the decorated test case.
-    max_consecutive_error: int, the maximum number of consecutively failed
-      iterations allowed. If reached, the remaining iterations is abandoned.
-      By default this is not enabled.
+    count: int, default number of times to execute the decorated test case.
+    max_consecutive_error: int, maximum number of consecutively failed
+      iterations allowed. If reached, remaining iterations are abandoned.
+    count_key: str, optional user_params key to override `count` at runtime.
+    max_consecutive_error_key: str, optional user_params key to override
+      `max_consecutive_error` at runtime.
 
   Returns:
     The wrapped test function.
-
-  Raises:
-    ValueError, if the user input is invalid.
   """
   if count <= 1:
     raise ValueError(
@@ -84,12 +89,14 @@
   if max_consecutive_error is not None and max_consecutive_error > count:
     raise ValueError(
         f'The `max_consecutive_error` ({max_consecutive_error}) for `repeat` '
-        f'must be smaller than `count` ({count}).'
+        f'must be <= `count` ({count}).'
     )
 
   def _outer_decorator(func):
     setattr(func, ATTR_REPEAT_CNT, count)
+    setattr(func, ATTR_REPEAT_CNT_KEY, count_key)
     setattr(func, ATTR_MAX_CONSEC_ERROR, max_consecutive_error)
+    setattr(func, ATTR_MAX_CONSEC_ERROR_KEY, max_consecutive_error_key)
 
     @functools.wraps(func)
     def _wrapper(*args):
@@ -100,25 +107,18 @@
   return _outer_decorator
 
 
-def retry(max_count):
+def retry(max_count: int, *, max_count_key: str | None = None):
   """Decorator for retrying a test case until it passes.
 
   The BaseTestClass will keep executing the test cases annotated with this
-  decorator until the test passes, or the maxinum number of iterations have
-  been met.
-
-  This decorator only stores the information needed for the retry. It does not
-  execute the retry.
+  decorator until the test passes or maximum iterations have been met.
 
   Args:
-    max_count: int, the maximum number of times to execute the decorated test
-      case.
+    max_count: int, default maximum number of iterations.
+    max_count_key: str, optional user_params key to override `max_count`.
 
   Returns:
     The wrapped test function.
-
-  Raises:
-    ValueError, if the user input is invalid.
   """
   if max_count <= 1:
     raise ValueError(
@@ -127,6 +127,7 @@
 
   def _outer_decorator(func):
     setattr(func, ATTR_MAX_RETRY_CNT, max_count)
+    setattr(func, ATTR_MAX_RETRY_CNT_KEY, max_count_key)
 
     @functools.wraps(func)
     def _wrapper(*args):
@@ -194,10 +195,7 @@
     self.tests = []
     class_identifier = self.__class__.__name__
     if configs.test_class_name_suffix:
-      class_identifier = '%s_%s' % (
-          class_identifier,
-          configs.test_class_name_suffix,
-      )
+      class_identifier = f'{class_identifier}_{configs.test_class_name_suffix}'
     if self.TAG is None:
       self.TAG = class_identifier
     # Set params.
@@ -256,7 +254,7 @@
         continue
       if name not in self.user_params:
         raise Error(
-            'Missing required user param "%s" in test configuration.' % name
+            f'Missing required user param "{name}" in test configuration.'
         )
       setattr(self, name, self.user_params[name])
     for name in opt_param_names:
@@ -373,12 +371,27 @@
       return True
     except Exception as e:
       logging.exception('%s failed for %s.', stage_name, self.TAG)
-      record.record_exception_chain(e)
+      self._record_class_error(record, e)
+      return False
+
+  def _record_class_error(self, record, e=None, exec_on_fail=False):
+    """Finalizes a class-level error record and writes it to results/summary.
+
+    Args:
+      record: A TestResultRecord object for the class stage.
+      e: Optional exception that caused the failure.
+      exec_on_fail: bool, whether to execute `_on_fail` procedure.
+    """
+    record.record_exception_chain(e)
+    record.update_record()
+    try:
+      if exec_on_fail:
+        self._exec_procedure_func(self._on_fail, record)
+    finally:
       self.results.add_class_error(record)
       self.summary_writer.dump(
           record.to_dict(), records.TestSummaryEntryType.RECORD
       )
-      return False
 
   def pre_run(self):
     """Preprocesses that need to be done before setup_class.
@@ -416,23 +429,11 @@
       # Setup class failed for unknown reasons.
       # Fail the class and skip all tests.
       logging.exception('Error in %s#setup_class.', self.TAG)
-      class_record.record_exception_chain(e)
-      self.results.add_class_error(class_record)
-      self._exec_procedure_func(self._on_fail, class_record)
-      class_record.update_record()
-      self.summary_writer.dump(
-          class_record.to_dict(), records.TestSummaryEntryType.RECORD
-      )
+      self._record_class_error(class_record, e, exec_on_fail=True)
       self._skip_remaining_tests(e)
       return self.results
     if expects.recorder.has_error:
-      self._exec_procedure_func(self._on_fail, class_record)
-      class_record.test_error()
-      class_record.update_record()
-      self.summary_writer.dump(
-          class_record.to_dict(), records.TestSummaryEntryType.RECORD
-      )
-      self.results.add_class_error(class_record)
+      self._record_class_error(class_record, exec_on_fail=True)
       self._skip_remaining_tests(class_record.termination_signal.exception)
       return self.results
 
@@ -466,20 +467,10 @@
       raise
     except Exception as e:
       logging.exception('Error encountered in %s.', stage_name)
-      record.record_exception_chain(e)
-      record.update_record()
-      self.results.add_class_error(record)
-      self.summary_writer.dump(
-          record.to_dict(), records.TestSummaryEntryType.RECORD
-      )
+      self._record_class_error(record, e)
     else:
       if expects.recorder.has_error:
-        record.test_error()
-        record.update_record()
-        self.results.add_class_error(record)
-        self.summary_writer.dump(
-            record.to_dict(), records.TestSummaryEntryType.RECORD
-        )
+        self._record_class_error(record)
     finally:
       self._clean_up()
 
@@ -508,19 +499,11 @@
     # reference tag as the parent token instead.
     if parent_token == stage_name:
       parent_token = self.TAG
-    logging.debug(
-        TEST_STAGE_BEGIN_LOG_TEMPLATE.format(
-            parent_token=parent_token, child_token=stage_name
-        )
-    )
+    logging.debug(TEST_STAGE_BEGIN_LOG_TEMPLATE, parent_token, stage_name)
     try:
       yield
     finally:
-      logging.debug(
-          TEST_STAGE_END_LOG_TEMPLATE.format(
-              parent_token=parent_token, child_token=stage_name
-          )
-      )
+      logging.debug(TEST_STAGE_END_LOG_TEMPLATE, parent_token, stage_name)
 
   def _setup_test(self, test_name):
     """Proxy function to guarantee the base implementation of setup_test is
@@ -640,7 +623,7 @@
         executed.
     """
     func_name = func.__name__
-    procedure_name = func_name[1:] if func_name[0] == '_' else func_name
+    procedure_name = func_name.removeprefix('_')
     with self._log_test_stage(procedure_name):
       try:
         # Pass a copy of the record instead of the actual object so that it
@@ -909,13 +892,12 @@
         is the corresponding UID.
     """
     self._assert_function_names_in_stack([STAGE_NAME_PRE_RUN])
-    root_msg = 'During test generation of "%s":' % test_logic.__name__
+    root_msg = f'During test generation of "{test_logic.__name__}":'
     for args in arg_sets:
       test_name = name_func(*args)
       if test_name in self.get_existing_test_names():
         raise Error(
-            '%s Test name "%s" already exists, cannot be duplicated!'
-            % (root_msg, test_name)
+            f'{root_msg} Test name "{test_name}" already exists, cannot be duplicated!'
         )
       test_func = functools.partial(test_logic, *args)
       # If the `test_logic` method is decorated by `retry` or `repeat`
@@ -924,8 +906,11 @@
       # also have the retry/repeat behavior.
       for attr_name in (
           ATTR_MAX_RETRY_CNT,
+          ATTR_MAX_RETRY_CNT_KEY,
           ATTR_MAX_CONSEC_ERROR,
+          ATTR_MAX_CONSEC_ERROR_KEY,
           ATTR_REPEAT_CNT,
+          ATTR_REPEAT_CNT_KEY,
       ):
         attr = getattr(test_logic, attr_name, None)
         if attr is not None:
@@ -960,6 +945,19 @@
           'Exception happened when executing %s in %s.', func.__name__, self.TAG
       )
 
+  def _resolve_user_param(
+      self, default_value: int | None, param_key: str | None
+  ) -> int | None:
+    """Returns default_value unless param_key is present in self.user_params."""
+    if param_key and param_key in self.user_params:
+      try:
+        return int(self.user_params[param_key])
+      except (ValueError, TypeError) as e:
+        raise ValueError(
+            f'Invalid integer in user_params["{param_key}"]: {e}'
+        ) from e
+    return default_value
+
   def get_existing_test_names(self):
     """Gets the names of existing tests in the class.
 
@@ -1037,8 +1035,8 @@
   def _assert_valid_test_name(self, test_name):
     if not test_name.startswith('test_'):
       raise Error(
-          'Test method name %s does not follow naming '
-          'convention test_*, abort.' % test_name
+          f'Test method name {test_name} does not follow naming '
+          'convention test_*, abort.'
       )
 
   def _skip_remaining_tests(self, exception):
@@ -1105,26 +1103,63 @@
         return setup_class_result
       # Run tests in order.
       for test_name, test_method in tests:
-        max_consecutive_error = getattr(test_method, ATTR_MAX_CONSEC_ERROR, 0)
-        repeat_count = getattr(test_method, ATTR_REPEAT_CNT, 0)
-        max_retry_count = getattr(test_method, ATTR_MAX_RETRY_CNT, 0)
-        if max_retry_count:
-          self._exec_one_test_with_retry(
-              test_name, test_method, max_retry_count
+        # 1. Resolve repeat count and max_consecutive_error
+        repeat_arg = getattr(test_method, ATTR_REPEAT_CNT, None)
+        repeat_key = getattr(test_method, ATTR_REPEAT_CNT_KEY, None)
+        repeat_cnt = self._resolve_user_param(repeat_arg, repeat_key)
+
+        if repeat_cnt is not None and repeat_cnt <= 1:
+          raise ValueError(
+              f'The `count` for `repeat` must be larger than 1, got "{repeat_cnt}" '
+              f'for test "{test_name}".'
           )
-        elif repeat_count:
+
+        consec_arg = getattr(test_method, ATTR_MAX_CONSEC_ERROR, None)
+        consec_key = getattr(test_method, ATTR_MAX_CONSEC_ERROR_KEY, None)
+        max_consec_err = self._resolve_user_param(consec_arg, consec_key)
+
+        if max_consec_err is not None:
+          if repeat_cnt is None:
+            raise ValueError(
+                f'The `max_consecutive_error` for `repeat` was set for test '
+                f'"{test_name}", but repeat count is not configured.'
+            )
+          if max_consec_err > repeat_cnt:
+            raise ValueError(
+                f'The `max_consecutive_error` ({max_consec_err}) for `repeat` '
+                f'must be <= `count` ({repeat_cnt}) for test "{test_name}".'
+            )
+
+        # 2. Resolve max retry count
+        retry_arg = getattr(test_method, ATTR_MAX_RETRY_CNT, None)
+        retry_key = getattr(test_method, ATTR_MAX_RETRY_CNT_KEY, None)
+        max_retry_cnt = self._resolve_user_param(retry_arg, retry_key)
+
+        if max_retry_cnt is not None and max_retry_cnt <= 1:
+          raise ValueError(
+              f'The `max_count` for `retry` must be larger than 1, got '
+              f'"{max_retry_cnt}" for test "{test_name}".'
+          )
+
+        # 3. Execute the test method based on resolved parameters
+        if max_retry_cnt is not None:
+          # Execute test with retry policy
+          self._exec_one_test_with_retry(test_name, test_method, max_retry_cnt)
+        elif repeat_cnt is not None:
+          # Execute test with repeat policy
           self._exec_one_test_with_repeat(
-              test_name, test_method, repeat_count, max_consecutive_error
+              test_name, test_method, repeat_cnt, max_consec_err or 0
           )
         else:
+          # Execute test as a regular single test
           self.exec_one_test(test_name, test_method)
       return self.results
     except signals.TestAbortClass as e:
-      e.details = 'Test class aborted due to: %s' % e.details
+      e.details = f'Test class aborted due to: {e.details}'
       self._skip_remaining_tests(e)
       return self.results
     except signals.TestAbortAll as e:
-      e.details = 'All remaining tests aborted due to: %s' % e.details
+      e.details = f'All remaining tests aborted due to: {e.details}'
       self._skip_remaining_tests(e)
       # Piggy-back test results on this exception object so we don't lose
       # results from this test class.
@@ -1150,9 +1185,4 @@
       self._record_controller_info()
       self._controller_manager.unregister_controllers()
       if expects.recorder.has_error:
-        record.test_error()
-        record.update_record()
-        self.results.add_class_error(record)
-        self.summary_writer.dump(
-            record.to_dict(), records.TestSummaryEntryType.RECORD
-        )
+        self._record_class_error(record)
diff --git a/mobly/controllers/android_device_lib/adb.py b/mobly/controllers/android_device_lib/adb.py
index 4934d84..dcf8438 100644
--- a/mobly/controllers/android_device_lib/adb.py
+++ b/mobly/controllers/android_device_lib/adb.py
@@ -68,7 +68,7 @@
   """
 
   def __init__(self, cmd, stdout, stderr, ret_code, serial=''):
-    super().__init__()
+    super().__init__(cmd, stdout, stderr, ret_code, serial)
     self.cmd = cmd
     self.stdout = stdout
     self.stderr = stderr
@@ -96,7 +96,7 @@
   """
 
   def __init__(self, cmd, timeout, serial=''):
-    super().__init__()
+    super().__init__(cmd, timeout, serial)
     self.cmd = cmd
     self.timeout = timeout
     self.serial = serial
@@ -339,8 +339,7 @@
         continue
       name = name.strip()[1:-1]
       # Remove any square bracket from either end of the value string.
-      if value and value[0] == '[':
-        value = value[1:]
+      value = value.removeprefix('[')
       results[name] = value
     return results
 
diff --git a/mobly/controllers/android_device_lib/fastboot.py b/mobly/controllers/android_device_lib/fastboot.py
index 0591e20..35d07cb 100644
--- a/mobly/controllers/android_device_lib/fastboot.py
+++ b/mobly/controllers/android_device_lib/fastboot.py
@@ -75,7 +75,7 @@
 
   def fastboot_str(self):
     if self.serial:
-      return '{} -s {}'.format(FASTBOOT, self.serial)
+      return f'{FASTBOOT} -s {self.serial}'
     return FASTBOOT
 
   def _exec_fastboot_cmd(self, name, arg_str, timeout=DEFAULT_TIMEOUT_SEC):
diff --git a/mobly/controllers/android_device_lib/jsonrpc_client_base.py b/mobly/controllers/android_device_lib/jsonrpc_client_base.py
index d272782..abdd8e7 100644
--- a/mobly/controllers/android_device_lib/jsonrpc_client_base.py
+++ b/mobly/controllers/android_device_lib/jsonrpc_client_base.py
@@ -211,9 +211,7 @@
       # Retry using '127.0.0.1' for IPv4 enabled machines that only resolve
       # 'localhost' to '[::1]'.
       self.log.debug(
-          'Failed to connect to localhost, trying 127.0.0.1: {}'.format(
-              str(err)
-          )
+          'Failed to connect to localhost, trying 127.0.0.1: %s', err
       )
       self._conn = socket.create_connection(
           ('127.0.0.1', self.host_port), _SOCKET_CONNECTION_TIMEOUT
diff --git a/mobly/controllers/android_device_lib/logcat_processor.py b/mobly/controllers/android_device_lib/logcat_processor.py
index e69de29..7ce2952 100644
--- a/mobly/controllers/android_device_lib/logcat_processor.py
+++ b/mobly/controllers/android_device_lib/logcat_processor.py
@@ -0,0 +1,622 @@
+# Copyright 2026 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.
+"""Logcat line parsing, timestamp comparison, and file reader utilities."""
+
+import collections
+from collections.abc import Iterable
+import dataclasses
+import os
+import queue
+import re
+import threading
+import time
+from typing import (
+    Any,
+    ClassVar,
+    Iterator,
+    Optional,
+    Pattern,
+    Sequence,
+    Set,
+    Union,
+)
+
+_LEVEL_NORM_MAP = {
+    'V': 'V',
+    'VERBOSE': 'V',
+    'D': 'D',
+    'DEBUG': 'D',
+    'I': 'I',
+    'INFO': 'I',
+    'W': 'W',
+    'WARN': 'W',
+    'WARNING': 'W',
+    'E': 'E',
+    'ERROR': 'E',
+    'F': 'F',
+    'FATAL': 'F',
+    'A': 'F',
+    'ASSERT': 'F',
+    'S': 'S',
+    'SILENT': 'S',
+}
+
+
+@dataclasses.dataclass(frozen=True)
+class LogcatPosition:
+  """A position marker representing a specific point in the logcat stream.
+
+  Attributes:
+    timestamp: Optional string timestamp corresponding to this position.
+    creation_time: Host epoch time when this position was marked.
+  """
+
+  timestamp: Optional[str] = None
+  creation_time: float = dataclasses.field(default_factory=time.time)
+  _byte_offset: int = 0
+
+  @classmethod
+  def from_file(
+      cls, file_path: str, timestamp: Optional[str] = None
+  ) -> 'LogcatPosition':
+    """Captures a snapshot of a logcat file at the current moment."""
+    try:
+      file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
+    except OSError:
+      file_size = 0
+    return cls(
+        timestamp=timestamp,
+        creation_time=time.time(),
+        _byte_offset=file_size,
+    )
+
+  @staticmethod
+  def _parse_timestamp(t: str) -> tuple[int, int, int, int, int, int, int]:
+    """Parses a timestamp into (year, month, day, hr, min, sec, microsec)."""
+    if not t:
+      raise ValueError('Empty timestamp string')
+
+    date_part, time_part = re.split(r'[\sT]+', t.strip(), maxsplit=1)
+    date_elements = [int(x) for x in re.split(r'[-/]', date_part)]
+    if len(date_elements) == 3:
+      year, month, day = date_elements
+    elif len(date_elements) == 2:
+      year = 0
+      month, day = date_elements
+    else:
+      raise ValueError(f'Invalid date elements in timestamp: {t}')
+
+    time_parts = time_part.split(':')
+    hour = int(time_parts[0])
+    minute = int(time_parts[1]) if len(time_parts) > 1 else 0
+    second, microsecond = 0, 0
+    if len(time_parts) > 2:
+      s_ms = time_parts[2].split('.', 1)
+      second = int(s_ms[0])
+      if len(s_ms) > 1:
+        microsecond = int(s_ms[1].ljust(6, '0')[:6])
+
+    return (year, month, day, hour, minute, second, microsecond)
+
+  @classmethod
+  def _compare_timestamps(cls, t1: Optional[str], t2: Optional[str]) -> int:
+    """Compares two logline timestamps chronologically."""
+    if not t1 and not t2:
+      return 0
+    if not t1:
+      return -1
+    if not t2:
+      return 1
+    try:
+      p1 = cls._parse_timestamp(t1)
+      p2 = cls._parse_timestamp(t2)
+      if p1[0] == 0 or p2[0] == 0:
+        p1 = (0,) + p1[1:]
+        p2 = (0,) + p2[1:]
+      return (p1 > p2) - (p1 < p2)
+    except (ValueError, IndexError):
+      str_t1, str_t2 = str(t1 or ''), str(t2 or '')
+      return (str_t1 > str_t2) - (str_t1 < str_t2)
+
+  def __lt__(self, other: Any) -> bool:
+    if not isinstance(other, LogcatPosition):
+      return NotImplemented
+    if self._byte_offset != other._byte_offset:
+      return self._byte_offset < other._byte_offset
+    return self._compare_timestamps(self.timestamp, other.timestamp) < 0
+
+  def __le__(self, other: Any) -> bool:
+    if not isinstance(other, LogcatPosition):
+      return NotImplemented
+    if self._byte_offset != other._byte_offset:
+      return self._byte_offset <= other._byte_offset
+    return self._compare_timestamps(self.timestamp, other.timestamp) <= 0
+
+  def __gt__(self, other: Any) -> bool:
+    if not isinstance(other, LogcatPosition):
+      return NotImplemented
+    if self._byte_offset != other._byte_offset:
+      return self._byte_offset > other._byte_offset
+    return self._compare_timestamps(self.timestamp, other.timestamp) > 0
+
+  def __ge__(self, other: Any) -> bool:
+    if not isinstance(other, LogcatPosition):
+      return NotImplemented
+    if self._byte_offset != other._byte_offset:
+      return self._byte_offset >= other._byte_offset
+    return self._compare_timestamps(self.timestamp, other.timestamp) >= 0
+
+
+@dataclasses.dataclass(frozen=True)
+class LogLine:
+  """Represents a single parsed Android logcat line in threadtime format.
+
+  Attributes:
+    position: LogcatPosition, position marker and timestamp of this log line.
+    pid: int, process ID.
+    tid: int, thread ID.
+    level: str, single-letter severity level ('V', 'D', 'I', 'W', 'E', 'F',
+      'S').
+    tag: str, log tag.
+    message: str, log message payload.
+    raw: str, original raw log line string without line endings.
+  """
+
+  position: LogcatPosition
+  pid: int
+  tid: int
+  level: str
+  tag: str
+  message: str
+  raw: str
+
+  _PATTERN: ClassVar[Pattern[str]] = re.compile(
+      r'^(?P<timestamp>(?:\d{4}[-/])?\d{2}[-/]\d{2}\s+'
+      r'\d{2}:\d{2}:\d{2}(?:\.\d+)?)'
+      r'\s+(?P<pid>\d+)'
+      r'\s+(?P<tid>\d+)'
+      r'\s+(?P<level>[VDIWEFSA])'
+      r'\s+(?P<tag>.*?)'
+      r'\s*:\s?'
+      r'(?P<message>.*)$'
+  )
+
+  @property
+  def timestamp(self) -> str:
+    """Returns the string timestamp of this log line."""
+    return self.position.timestamp or ''
+
+  @classmethod
+  def from_string(cls, line: str, byte_offset: int = 0) -> Optional['LogLine']:
+    """Parses a raw logcat line in threadtime format into a LogLine object."""
+    if not line or not isinstance(line, str):
+      return None
+
+    clean_line = line.rstrip('\r\n')
+    match = cls._PATTERN.match(clean_line)
+    if not match:
+      return None
+
+    try:
+      pos = LogcatPosition(
+          timestamp=match.group('timestamp'),
+          _byte_offset=byte_offset,
+      )
+      return cls(
+          position=pos,
+          pid=int(match.group('pid')),
+          tid=int(match.group('tid')),
+          level=match.group('level'),
+          tag=match.group('tag'),
+          message=match.group('message'),
+          raw=clean_line,
+      )
+    except (ValueError, TypeError, IndexError):
+      return None
+
+  def matches(
+      self,
+      pattern: Optional[Union[str, Pattern[str]]] = None,
+      tag: Optional[Union[str, Pattern[str], Sequence[str], Set[str]]] = None,
+      level: Optional[Union[str, Sequence[str], Set[str]]] = None,
+  ) -> bool:
+    """Checks if this log line matches the given pattern, tag, and/or level."""
+    if pattern is not None:
+      regex = re.compile(pattern) if isinstance(pattern, str) else pattern
+      if not (regex.search(self.message) or regex.search(self.raw)):
+        return False
+
+    if tag is not None:
+      if isinstance(tag, str):
+        if self.tag != tag:
+          return False
+      elif hasattr(tag, 'search'):
+        if not tag.search(self.tag):
+          return False
+      elif isinstance(tag, Iterable) and self.tag not in tag:
+        return False
+
+    if level is not None:
+      levels = {level} if isinstance(level, str) else set(level)
+      norm_levels = {
+          _LEVEL_NORM_MAP.get(str(l).upper(), str(l).upper()) for l in levels
+      }
+      self_norm = _LEVEL_NORM_MAP.get(self.level.upper(), self.level.upper())
+      if self.level not in levels and self_norm not in norm_levels:
+        return False
+
+    return True
+
+  @property
+  def is_error(self) -> bool:
+    """Returns True if this line represents an error or fatal severity."""
+    return self.level.upper() in ('E', 'F', 'A')
+
+  def __lt__(self, other: Any) -> bool:
+    if not isinstance(other, LogLine):
+      return NotImplemented
+    return self.position < other.position
+
+  def __le__(self, other: Any) -> bool:
+    if not isinstance(other, LogLine):
+      return NotImplemented
+    return self.position <= other.position
+
+  def __gt__(self, other: Any) -> bool:
+    if not isinstance(other, LogLine):
+      return NotImplemented
+    return self.position > other.position
+
+  def __ge__(self, other: Any) -> bool:
+    if not isinstance(other, LogLine):
+      return NotImplemented
+    return self.position >= other.position
+
+
+class LogcatListenerContext:
+  """Context manager for listening to real-time logcat events."""
+
+  def __init__(
+      self,
+      processor: 'LogcatProcessor',
+      pattern: Optional[Union[str, Pattern[str]]] = None,
+      tag: Optional[Union[str, Pattern[str], Sequence[str], Set[str]]] = None,
+      level: Optional[Union[str, Sequence[str], Set[str]]] = None,
+      position: Optional[Union[LogcatPosition, LogLine]] = None,
+      max_events: int = 1000,
+      timeout_error_cls: type[Exception] = TimeoutError,
+  ):
+    self._processor = processor
+    self._pattern = pattern
+    self._tag = tag
+    self._level = level
+    self._position = (
+        position.position if isinstance(position, LogLine) else position
+    )
+    self._max_events = max_events
+    self._timeout_error_cls = timeout_error_cls
+    self._events: collections.deque[LogLine] = collections.deque(
+        maxlen=max_events
+    )
+    self._queue: queue.Queue[LogLine] = queue.Queue(maxsize=max_events)
+    self._lock = threading.Lock()
+    self._stop_event = threading.Event()
+    self._thread: Optional[threading.Thread] = None
+
+  @property
+  def events(self) -> list[LogLine]:
+    """Returns a snapshot list of captured events."""
+    with self._lock:
+      return list(self._events)
+
+  def has_events(self) -> bool:
+    """Returns True if any events have been captured."""
+    with self._lock:
+      return bool(self._events)
+
+  def get_next_event(self, timeout: Optional[float] = None) -> LogLine:
+    """Gets the next event from the queue, blocking up to timeout seconds."""
+    try:
+      return self._queue.get(block=True, timeout=timeout)
+    except queue.Empty:
+      raise self._timeout_error_cls(
+          f'Timed out after {timeout}s waiting for next logcat event '
+          f'(pattern={self._pattern!r}, tag={self._tag!r},'
+          f' level={self._level!r})'
+      )
+
+  def _dispatch(self, line: LogLine) -> None:
+    if line.matches(pattern=self._pattern, tag=self._tag, level=self._level):
+      with self._lock:
+        self._events.append(line)
+      try:
+        self._queue.put_nowait(line)
+      except queue.Full:
+        pass
+
+  def _listen_loop(self) -> None:
+    current_offset = (
+        self._position._byte_offset
+        if self._position
+        else LogcatPosition.from_file(self._processor.file_path)._byte_offset
+    )
+    while not self._stop_event.is_set():
+      for offset, line in self._processor._iter_lines(offset=current_offset):
+        current_offset = offset
+        self._dispatch(line)
+        if self._stop_event.is_set():
+          break
+      time.sleep(0.05)
+
+  def __enter__(self) -> 'LogcatListenerContext':
+    self._stop_event.clear()
+    self._thread = threading.Thread(target=self._listen_loop, daemon=True)
+    self._thread.start()
+    return self
+
+  def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+    self._stop_event.set()
+    if self._thread and self._thread.is_alive():
+      self._thread.join(timeout=2.0)
+    self._thread = None
+
+
+class LogcatProcessor:
+  """Thread-safe processor for querying and streaming logcat files."""
+
+  def __init__(
+      self,
+      file_path: str,
+      timeout_error_cls: type[Exception] = TimeoutError,
+  ):
+    self._file_path = file_path
+    self._timeout_error_cls = timeout_error_cls
+
+  @property
+  def file_path(self) -> str:
+    return self._file_path
+
+  def _iter_lines(self, offset: int = 0) -> Iterator[tuple[int, LogLine]]:
+    """Yields (line_offset, LogLine) pairs from file from given offset."""
+    if not os.path.exists(self._file_path):
+      return
+    try:
+      with open(
+          self._file_path, 'r', encoding='utf-8', errors='replace', newline=''
+      ) as f:
+        if offset > 0:
+          f.seek(offset)
+        while True:
+          line_offset = f.tell()
+          line = f.readline()
+          if not line:
+            break
+          current_offset = f.tell()
+          parsed = LogLine.from_string(line, byte_offset=line_offset)
+          if parsed is not None:
+            yield current_offset, parsed
+    except OSError:
+      return
+
+  def get_lines(
+      self,
+      pattern: Optional[Union[str, Pattern[str]]] = None,
+      *,
+      tag: Optional[Union[str, Pattern[str], Sequence[str], Set[str]]] = None,
+      level: Optional[Union[str, Sequence[str], Set[str]]] = None,
+      since: Optional[Union[LogcatPosition, LogLine]] = None,
+      max_lines: Optional[int] = None,
+  ) -> list[LogLine]:
+    """Gets log lines from the file satisfying filter criteria."""
+    if (
+        pattern is None
+        and tag is None
+        and level is None
+        and since is None
+        and max_lines is None
+    ):
+      raise ValueError(
+          'At least one filter criteria (pattern, tag, level, since, or'
+          ' max_lines) must be specified. To inspect the latest logs, use'
+          ' tail() instead.'
+      )
+
+    pos = since.position if isinstance(since, LogLine) else since
+    offset = pos._byte_offset if pos else 0
+    begin_time = pos.timestamp if pos and offset == 0 else None
+
+    results: list[LogLine] = []
+    for _, parsed in self._iter_lines(offset=offset):
+      if (
+          begin_time
+          and LogcatPosition._compare_timestamps(parsed.timestamp, begin_time)
+          < 0
+      ):
+        continue
+      if parsed.matches(pattern=pattern, tag=tag, level=level):
+        results.append(parsed)
+        if max_lines is not None and len(results) >= max_lines:
+          break
+    return results
+
+  def tail(
+      self,
+      num_lines: int = 100,
+      pattern: Optional[Union[str, Pattern[str]]] = None,
+      tag: Optional[Union[str, Pattern[str], Sequence[str], Set[str]]] = None,
+      level: Optional[Union[str, Sequence[str], Set[str]]] = None,
+  ) -> list[LogLine]:
+    """Tails last num_lines matching log lines reading backwards from EOF."""
+    if num_lines <= 0 or not os.path.exists(self._file_path):
+      return []
+
+    buf: collections.deque[LogLine] = collections.deque()
+    block_size = 64 * 1024  # 64KB chunks
+
+    try:
+      with open(self._file_path, 'rb') as f:
+        f.seek(0, os.SEEK_END)
+        file_size = f.tell()
+        if file_size == 0:
+          return []
+
+        remaining = file_size
+        remainder = b''
+        lines_to_process: list[tuple[int, bytes]] = []
+
+        while remaining > 0 and len(buf) < num_lines:
+          read_size = min(block_size, remaining)
+          remaining -= read_size
+          f.seek(remaining)
+          chunk = f.read(read_size)
+          data = chunk + remainder
+
+          # Split lines from chunk
+          split = data.split(b'\n')
+          if remaining > 0:
+            remainder = split[0]
+            lines_chunk = split[1:]
+          else:
+            remainder = b''
+            lines_chunk = split
+
+          # Calculate offsets and parse lines in reverse order within this block
+          current_offset = remaining + len(remainder)
+          block_lines: list[tuple[int, LogLine]] = []
+          for line_bytes in lines_chunk:
+            line_offset = current_offset
+            current_offset += len(line_bytes) + 1  # count \n byte
+            line_str = line_bytes.decode('utf-8', errors='replace')
+            parsed = LogLine.from_string(line_str, byte_offset=line_offset)
+            if parsed is not None:
+              block_lines.append((line_offset, parsed))
+
+          for _, parsed in reversed(block_lines):
+            if parsed.matches(pattern=pattern, tag=tag, level=level):
+              buf.appendleft(parsed)
+              if len(buf) >= num_lines:
+                break
+    except OSError:
+      return []
+
+    return list(buf)
+
+  def listen(
+      self,
+      pattern: Optional[Union[str, Pattern[str]]] = None,
+      tag: Optional[Union[str, Pattern[str], Sequence[str], Set[str]]] = None,
+      level: Optional[Union[str, Sequence[str], Set[str]]] = None,
+      position: Optional[Union[LogcatPosition, LogLine]] = None,
+  ) -> LogcatListenerContext:
+    """Listens for real-time logcat events in a context manager."""
+    return LogcatListenerContext(
+        processor=self,
+        pattern=pattern,
+        tag=tag,
+        level=level,
+        position=position,
+        timeout_error_cls=self._timeout_error_cls,
+    )
+
+  def wait_for(
+      self,
+      patterns: Sequence[Union[str, Pattern[str]]],
+      timeout_sec: float = 60.0,
+      in_order: bool = True,
+      since: Optional[Union[LogcatPosition, LogLine]] = None,
+  ) -> list[LogLine]:
+    """Waits until a sequence of patterns appears in logcat."""
+    if not patterns:
+      return []
+
+    deadline = time.perf_counter() + timeout_sec
+
+    if in_order:
+      matched_lines: list[LogLine] = []
+      current_since = since
+      for pat in patterns:
+        remaining = deadline - time.perf_counter()
+        if remaining <= 0:
+          raise self._timeout_error_cls(
+              f'Timed out after {timeout_sec}s waiting for in-order pattern:'
+              f' {pat!r}'
+          )
+        matched, next_offset = self._wait_for_single(
+            pattern=pat,
+            timeout_sec=remaining,
+            since=current_since,
+        )
+        matched_lines.append(matched)
+        current_since = LogcatPosition(_byte_offset=next_offset)
+      return matched_lines
+
+    unmatched = list(enumerate(patterns))
+    matched_dict: dict[int, LogLine] = {}
+    pos = since.position if isinstance(since, LogLine) else since
+    offset = pos._byte_offset if pos else 0
+    begin_time = pos.timestamp if pos and offset == 0 else None
+    scan_offset = offset
+
+    while time.perf_counter() < deadline:
+      for current_offset, parsed in self._iter_lines(offset=scan_offset):
+        scan_offset = current_offset
+        if (
+            begin_time
+            and LogcatPosition._compare_timestamps(parsed.timestamp, begin_time)
+            < 0
+        ):
+          continue
+        for idx, pat in list(unmatched):
+          if parsed.matches(pattern=pat):
+            matched_dict[idx] = parsed
+            unmatched.remove((idx, pat))
+        if not unmatched:
+          return [matched_dict[i] for i in range(len(patterns))]
+      time.sleep(0.1)
+
+    remaining_patterns = [pat for _, pat in unmatched]
+    raise self._timeout_error_cls(
+        f'Timed out after {timeout_sec}s waiting for patterns:'
+        f' {remaining_patterns!r}'
+    )
+
+  def _wait_for_single(
+      self,
+      pattern: Union[str, Pattern[str]],
+      timeout_sec: float = 60.0,
+      since: Optional[Union[LogcatPosition, LogLine]] = None,
+  ) -> tuple[LogLine, int]:
+    deadline = time.perf_counter() + timeout_sec
+    pos = since.position if isinstance(since, LogLine) else since
+    offset = pos._byte_offset if pos else 0
+    begin_time = pos.timestamp if pos and offset == 0 else None
+    scan_offset = offset
+
+    while time.perf_counter() < deadline:
+      for current_offset, parsed in self._iter_lines(offset=scan_offset):
+        scan_offset = current_offset
+        if (
+            begin_time
+            and LogcatPosition._compare_timestamps(parsed.timestamp, begin_time)
+            < 0
+        ):
+          continue
+        if parsed.matches(pattern=pattern):
+          return parsed, current_offset
+      time.sleep(0.1)
+
+    raise self._timeout_error_cls(
+        f'Timed out after {timeout_sec}s waiting for logcat pattern:'
+        f' {pattern!r}'
+    )
diff --git a/mobly/controllers/android_device_lib/services/logcat.py b/mobly/controllers/android_device_lib/services/logcat.py
index 714954b..f17681c 100644
--- a/mobly/controllers/android_device_lib/services/logcat.py
+++ b/mobly/controllers/android_device_lib/services/logcat.py
@@ -11,14 +11,16 @@
 # 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.
+
 import logging
 import os
 import time
+from typing import Any, Callable, Optional, Pattern, Sequence, Set, Union
 
-from mobly import logger as mobly_logger
 from mobly import utils
 from mobly.controllers.android_device_lib import adb
 from mobly.controllers.android_device_lib import errors
+from mobly.controllers.android_device_lib import logcat_processor
 from mobly.controllers.android_device_lib.services import base_service
 
 CREATE_LOGCAT_FILE_TIMEOUT_SEC = 5
@@ -30,15 +32,19 @@
   SERVICE_TYPE = 'Logcat'
 
 
+class LogcatTimeoutError(Error):
+  """Raised when a logcat wait operation times out."""
+
+
 class Config:
   """Config object for logcat service.
 
   Attributes:
     clear_log: bool, clears the logcat before collection if True.
     logcat_params: string, extra params to be added to logcat command.
-    output_file_path: string, the path on the host to write the log file
-      to, including the actual filename. The service will automatically
-      generate one if not specified.
+    output_file_path: string, the path on the host to write the log file to,
+      including the actual filename. The service will automatically generate one
+      if not specified.
   """
 
   def __init__(self, logcat_params=None, clear_log=True, output_file_path=None):
@@ -51,8 +57,8 @@
   """Android logcat service for Mobly's AndroidDevice controller.
 
   Attributes:
-    adb_logcat_file_path: string, path to the file that the service writes
-      adb logcat to by default.
+    adb_logcat_file_path: string, path to the file that the service writes adb
+      logcat to by default.
   """
 
   OUTPUT_FILE_TYPE = 'logcat'
@@ -63,10 +69,22 @@
     self._adb_logcat_process = None
     self._adb_logcat_file_obj = None
     self.adb_logcat_file_path = None
+    self._processor = None
+    self._last_connection_time = None
     # Logcat service uses a single config obj, using singular internal
     # name: `_config`.
     self._config = configs if configs else Config()
 
+  def _get_processor(self) -> logcat_processor.LogcatProcessor:
+    if self._processor is None:
+      if not self.adb_logcat_file_path:
+        raise Error(self._ad, 'Logcat service has not been started.')
+      self._processor = logcat_processor.LogcatProcessor(
+          self.adb_logcat_file_path,
+          timeout_error_cls=lambda msg: LogcatTimeoutError(self._ad, msg),
+      )
+    return self._processor
+
   def _enable_logpersist(self):
     """Attempts to enable logpersist daemon to persist logs."""
     # Logpersist is only allowed on rootable devices because of excessive
@@ -75,8 +93,8 @@
       return
 
     logpersist_warning = (
-        '%s encountered an error enabling persistent'
-        ' logs, logs may not get saved.'
+        '%s encountered an error enabling persistent logs, logs may not get'
+        ' saved.'
     )
     # Android L and older versions do not have logpersist installed,
     # so check that the logpersist scripts exists before trying to use
@@ -94,13 +112,208 @@
     except adb.AdbError:
       logging.warning(logpersist_warning, self)
 
-  def _is_timestamp_in_range(self, target, begin_time, end_time):
-    low = mobly_logger.logline_timestamp_comparator(begin_time, target) <= 0
-    high = mobly_logger.logline_timestamp_comparator(end_time, target) >= 0
-    return low and high
+  def _get_device_time(self) -> Optional[str]:
+    """Retrieves current timestamp from device via ADB date command."""
+    try:
+      response = self._ad.adb.shell(['date', r'+%Y-%m-%d\ %H:%M:%S.%3N'])
+      if response:
+        return response.decode('utf-8').strip()
+    except (adb.AdbError, UnicodeDecodeError):
+      self._ad.log.debug('Failed to get device timestamp.')
+    return None
+
+  def now(self) -> logcat_processor.LogcatPosition:
+    """Captures the current logcat position and device timestamp.
+
+    Creates a position marker representing the logcat stream right now to bound
+    subsequent queries or wait operations.
+
+    Examples::
+
+      start = ad.services.logcat.now()
+      ad.droid.wifiEnable()
+      lines = ad.services.logcat.get_lines('STATE_CONNECTED', since=start)
+
+    Returns:
+      A :class:`logcat_processor.LogcatPosition` instance representing the
+      current log state.
+    """
+    return logcat_processor.LogcatPosition.from_file(
+        self.adb_logcat_file_path or '',
+        timestamp=self._get_device_time(),
+    )
+
+  def get_lines(
+      self,
+      pattern: Optional[Union[str, Pattern[str]]] = None,
+      *,
+      tag: Optional[Union[str, Pattern[str], Sequence[str], Set[str]]] = None,
+      level: Optional[Union[str, Sequence[str], Set[str]]] = None,
+      since: Optional[
+          Union[logcat_processor.LogcatPosition, logcat_processor.LogLine]
+      ] = None,
+      max_lines: Optional[int] = None,
+  ) -> list[logcat_processor.LogLine]:
+    """Gets log lines from the logcat file matching the given filters.
+
+    Filters are evaluated conjunctively. At least one filter criteria
+    (``pattern``, ``tag``, ``level``, ``since``, or ``max_lines``) must be
+    specified. To retrieve the latest un-filtered logs, use :meth:`tail`
+    instead.
+
+    Examples::
+
+      # Find error logs for a specific tag
+      lines = ad.services.logcat.get_lines(tag='ActivityManager', level='E')
+
+      # Find pattern matches since a marked position
+      start = ad.services.logcat.now()
+      ad.droid.wifiEnable()
+      lines = ad.services.logcat.get_lines('WiFi connected', since=start)
+
+    Args:
+      pattern: Regular expression pattern matched against message and raw line.
+      tag: Tag string, compiled regex pattern, or collection of tags to match.
+      level: Severity level string ('V', 'D', 'I', 'W', 'E', 'F') or collection.
+      since: Optional :class:`logcat_processor.LogcatPosition` or
+        :class:`logcat_processor.LogLine` bounding search start.
+      max_lines: Maximum number of matching log lines to return.
+
+    Returns:
+      A list of matching
+      :class:`~mobly.controllers.android_device_lib.logcat_processor.LogLine`
+      objects in file order.
+
+    Raises:
+      ValueError: If no filter criteria are provided.
+    """
+    return self._get_processor().get_lines(
+        pattern=pattern,
+        tag=tag,
+        level=level,
+        since=since,
+        max_lines=max_lines,
+    )
+
+  def tail(
+      self,
+      num_lines: int = 100,
+      pattern: Optional[Union[str, Pattern[str]]] = None,
+      tag: Optional[Union[str, Pattern[str], Sequence[str], Set[str]]] = None,
+      level: Optional[Union[str, Sequence[str], Set[str]]] = None,
+  ) -> list[logcat_processor.LogLine]:
+    """Tails the last matching log lines from the logcat file.
+
+    Examples::
+
+      # Get the last 50 log lines
+      recent_lines = ad.services.logcat.tail(num_lines=50)
+
+      # Get the last 5 fatal errors
+      fatal_lines = ad.services.logcat.tail(num_lines=5, level='F')
+
+    Args:
+      num_lines: Number of matching lines to return from the end of the file.
+      pattern: Optional regex pattern filter.
+      tag: Optional tag filter.
+      level: Optional severity level filter.
+
+    Returns:
+      A list of the last ``num_lines`` matching
+      :class:`~mobly.controllers.android_device_lib.logcat_processor.LogLine`
+      objects.
+    """
+    return self._get_processor().tail(
+        num_lines=num_lines,
+        pattern=pattern,
+        tag=tag,
+        level=level,
+    )
+
+  def listen(
+      self,
+      pattern: Optional[Union[str, Pattern[str]]] = None,
+      tag: Optional[Union[str, Pattern[str], Sequence[str], Set[str]]] = None,
+      level: Optional[Union[str, Sequence[str], Set[str]]] = None,
+  ) -> logcat_processor.LogcatListenerContext:
+    """Listens for real-time logcat events within a scoped context manager.
+
+    Automatically captures current logcat position on entry and cleans up
+    background workers upon exiting the context.
+
+    Examples::
+
+      with ad.services.logcat.listen(tag='WifiService') as listener:
+        ad.droid.wifiEnable()
+        event = listener.get_next_event(timeout=10.0)
+        assert 'STATE_CONNECTED' in event.message
+
+    Args:
+      pattern: Optional regex pattern to filter incoming events.
+      tag: Optional tag filter.
+      level: Optional severity level filter.
+
+    Returns:
+      A :class:`logcat_processor.LogcatListenerContext` managing event queue
+      and background stream.
+    """
+    cursor = self.now()
+    return self._get_processor().listen(
+        pattern=pattern,
+        tag=tag,
+        level=level,
+        position=cursor,
+    )
+
+  def wait_for(
+      self,
+      patterns: Sequence[Union[str, Pattern[str]]],
+      timeout_sec: float = 60.0,
+      in_order: bool = True,
+      since: Optional[
+          Union[logcat_processor.LogcatPosition, logcat_processor.LogLine]
+      ] = None,
+  ) -> list[logcat_processor.LogLine]:
+    """Waits until pattern(s) appear in logcat within the given timeout.
+
+    Examples::
+
+      # Wait for a single pattern
+      lines = ad.services.logcat.wait_for(
+          ['Bluetooth connected'], timeout_sec=10.0
+      )
+
+      # Wait for multiple patterns in sequential order
+      steps = ['DHCP DISCOVER', 'DHCP OFFER', 'DHCP ACK']
+      lines = ad.services.logcat.wait_for(
+          steps, in_order=True, timeout_sec=15.0
+      )
+
+    Args:
+      patterns: Sequence of string patterns or compiled regular expressions.
+      timeout_sec: Maximum wall-clock time in seconds to wait before timing out.
+      in_order: Whether patterns must occur in the specified sequential order
+        (True) or any order (False).
+      since: Optional :class:`logcat_processor.LogcatPosition` or
+        :class:`logcat_processor.LogLine` bounding search start.
+
+    Returns:
+      A list of matching :class:`logcat_processor.LogLine` objects
+      corresponding to each pattern in ``patterns``.
+
+    Raises:
+      LogcatTimeoutError: If matching pattern(s) are not found within
+        ``timeout_sec``.
+    """
+    return self._get_processor().wait_for(
+        patterns=patterns,
+        timeout_sec=timeout_sec,
+        in_order=in_order,
+        since=since,
+    )
 
   def create_output_excerpts(self, test_info):
-    """Convenient method for creating excerpts of adb logcat.
+    """Creates excerpts of adb logcat copied from current stream.
 
     This copies logcat lines from self.adb_logcat_file_path to an excerpt
     file, starting from the location where the previous excerpt ended.
@@ -125,7 +338,7 @@
         'w',
         encoding='utf-8',
         errors='replace',
-        # When newline is '', line endings are written without conversion.
+        # When newline is '', line endings are read without conversion.
         newline='',
     ) as out:
       # Devices may accidentally go offline during test,
@@ -185,9 +398,11 @@
         new_config,
     )
     self._config = new_config
+    self._processor = None
 
   def _open_logcat_file(self):
-    """Create a file object that points to the beginning of the logcat file.
+    """Creates a file object that points to the beginning of the logcat file.
+
     Wait for the logcat file to be created by the subprocess if it doesn't
     exist.
     """
@@ -232,7 +447,7 @@
     self._open_logcat_file()
 
   def _start(self):
-    """The actual logic of starting logcat."""
+    """Starts the actual subprocess logic of starting logcat."""
     self._enable_logpersist()
     if self._config.output_file_path:
       self._close_logcat_file()
@@ -243,13 +458,23 @@
       )
       logcat_file_path = os.path.join(self._ad.log_path, f_name)
       self.adb_logcat_file_path = logcat_file_path
+    self._processor = logcat_processor.LogcatProcessor(
+        self.adb_logcat_file_path,
+        timeout_error_cls=lambda msg: LogcatTimeoutError(self._ad, msg),
+    )
     utils.create_dir(os.path.dirname(self.adb_logcat_file_path))
     # In debugging mode of IntelijIDEA, "patch_args" remove
     # double quotes in args if starting and ending with it.
     # Add spaces at beginning and at last to fix this issue.
-    cmd = ' "%s" -s %s logcat -v threadtime -T 1 %s >> "%s" ' % (
+    if self._last_connection_time is not None:
+      t_argument_value = self._last_connection_time
+      self._last_connection_time = None
+    else:
+      t_argument_value = '1'
+    cmd = ' "%s" -s %s logcat -v threadtime -T "%s" %s >> "%s" ' % (
         adb.ADB,
         self._ad.serial,
+        t_argument_value,
         self._config.logcat_params,
         self.adb_logcat_file_path,
     )
@@ -270,15 +495,22 @@
     except Exception:
       self._ad.log.exception('Failed to stop adb logcat.')
     self._adb_logcat_process = None
+    self._last_connection_time = None
 
   def pause(self):
-    """Pauses logcat.
+    """Pauses logcat collection.
 
     Note: the service is unable to collect the logs when paused, if more
     logs are generated on the device than the device's log buffer can hold,
     some logs would be lost.
     """
     self._stop()
+    try:
+      response = self._ad.adb.shell(['date', r'+%Y-%m-%d\ %H:%M:%S.%3N'])
+      if response:
+        self._last_connection_time = response.decode('utf-8').strip()
+    except adb.AdbError:
+      self._ad.log.exception('Failed to get device time.')
 
   def resume(self):
     """Resumes a paused logcat service."""
diff --git a/mobly/controllers/android_device_lib/snippet_client.py b/mobly/controllers/android_device_lib/snippet_client.py
deleted file mode 100644
index 099f9c7..0000000
--- a/mobly/controllers/android_device_lib/snippet_client.py
+++ /dev/null
@@ -1,419 +0,0 @@
-# Copyright 2016 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.
-"""JSON RPC interface to Mobly Snippet Lib."""
-
-import logging
-import re
-import time
-
-from mobly import utils
-from mobly.controllers.android_device_lib import adb
-from mobly.controllers.android_device_lib import errors
-from mobly.controllers.android_device_lib import jsonrpc_client_base
-from mobly.snippet import errors as snippet_errors
-
-logging.warning(
-    'The module mobly.controllers.android_device_lib.snippet_client'
-    ' is deprecated and will be removed in a future version. Use'
-    ' module mobly.controllers.android_device_lib.snippet_client_v2'
-    ' instead.'
-)
-
-_INSTRUMENTATION_RUNNER_PACKAGE = (
-    'com.google.android.mobly.snippet.SnippetRunner'
-)
-
-# 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
-
-_LAUNCH_CMD = (
-    '{shell_cmd} am instrument {user} -w -e action start {snippet_package}/'
-    + _INSTRUMENTATION_RUNNER_PACKAGE
-)
-
-_STOP_CMD = (
-    'am instrument {user} -w -e action stop {snippet_package}/'
-    + _INSTRUMENTATION_RUNNER_PACKAGE
-)
-
-# 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 USB 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 them only if exists.
-_SETSID_COMMAND = 'setsid'
-
-_NOHUP_COMMAND = 'nohup'
-
-# Aliases of error types for backward compatibility.
-AppStartPreCheckError = snippet_errors.ServerStartPreCheckError
-ProtocolVersionError = snippet_errors.ServerStartProtocolError
-
-
-class SnippetClient(jsonrpc_client_base.JsonRpcClientBase):
-  """A client for interacting with snippet APKs using Mobly Snippet Lib.
-
-  DEPRECATED: Use
-  mobly.controllers.android_device_lib.snippet_client_v2.SnippetClientV2
-  instead.
-
-  See superclass documentation for a list of public attributes.
-
-  For a description of the launch protocols, see the documentation in
-  mobly-snippet-lib, SnippetRunner.java.
-  """
-
-  def __init__(self, package, ad):
-    """Initializes a SnippetClient.
-
-    Args:
-      package: (str) The package name of the apk where the snippets are
-        defined.
-      ad: (AndroidDevice) the device object associated with this client.
-    """
-    super().__init__(app_name=package, ad=ad)
-    self.package = package
-    self._ad = ad
-    self._adb = ad.adb
-    self._proc = None
-    self._user_id = None
-
-  @property
-  def is_alive(self):
-    """Does the client have an active connection to the snippet server."""
-    return self._conn is not None
-
-  @property
-  def user_id(self):
-    """The user id to use for this snippet client.
-
-    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.
-
-    Because all the operations of the snippet client should be done for a
-    partucular user.
-    """
-    if self._user_id is None:
-      self._user_id = self._adb.current_user_id
-    return self._user_id
-
-  def _get_user_command_string(self):
-    """Gets the appropriate command argument for specifying user IDs.
-
-    By default, `SnippetClient` operates within the current user.
-
-    We don't add the `--user {ID}` arg when Android's SDK is below 24,
-    where multi-user support is not well implemented.
-
-    Returns:
-      String, the command param section to be formatted into the adb
-      commands.
-    """
-    sdk_int = int(self._ad.build_info['build_version_sdk'])
-    if sdk_int < 24:
-      return ''
-    return f'--user {self.user_id}'
-
-  def start_app_and_connect(self):
-    """Starts snippet apk on the device and connects to it.
-
-    This wraps the main logic with safe handling
-
-    Raises:
-      AppStartPreCheckError, when pre-launch checks fail.
-    """
-    try:
-      self._start_app_and_connect()
-    except AppStartPreCheckError:
-      # Precheck errors don't need cleanup, directly raise.
-      raise
-    except Exception as e:
-      # Log the stacktrace of `e` as re-raising doesn't preserve trace.
-      self._ad.log.exception('Failed to start app and connect.')
-      # If errors happen, make sure we clean up before raising.
-      try:
-        self.stop_app()
-      except Exception:
-        self._ad.log.exception(
-            'Failed to stop app after failure to start and connect.'
-        )
-      # Explicitly raise the original error from starting app.
-      raise e
-
-  def _start_app_and_connect(self):
-    """Starts snippet apk on the device and connects to it.
-
-    After prechecks, this launches the snippet apk with an adb cmd in a
-    standing subprocess, checks the cmd response from the apk for protocol
-    version, then sets up the socket connection over adb port-forwarding.
-
-    Args:
-      ProtocolVersionError, if protocol info or port info cannot be
-        retrieved from the snippet apk.
-    """
-    self._check_app_installed()
-    self.disable_hidden_api_blacklist()
-
-    persists_shell_cmd = self._get_persist_command()
-    # Use info here so people can follow along with the snippet startup
-    # process. Starting snippets can be slow, especially if there are
-    # multiple, and this avoids the perception that the framework is hanging
-    # for a long time doing nothing.
-    self.log.info(
-        'Launching snippet apk %s with 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,
-    )
-    start_time = time.perf_counter()
-    self._proc = self._do_start_app(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 match.group(1) != '1':
-      raise ProtocolVersionError(self._ad, line)
-
-    line = self._read_protocol_line()
-    match = re.match('^SNIPPET SERVING, PORT ([0-9]+)$', line)
-    if not match:
-      raise ProtocolVersionError(self._ad, line)
-    self.device_port = int(match.group(1))
-
-    # Forward the device port to a new host port, and connect to that port
-    self.host_port = utils.get_available_host_port()
-    self._adb.forward(['tcp:%d' % self.host_port, 'tcp:%d' % self.device_port])
-    self.connect()
-
-    # Yaaay! We're done!
-    self.log.debug(
-        'Snippet %s started after %.1fs on host port %s',
-        self.package,
-        time.perf_counter() - start_time,
-        self.host_port,
-    )
-
-  def restore_app_connection(self, port=None):
-    """Restores the app after device got reconnected.
-
-    Instead of creating new instance of the client:
-      - Uses the given port (or find a new available host_port if none is
-      given).
-      - Tries to connect to remote server with selected port.
-
-    Args:
-      port: If given, this is the host port from which to connect to remote
-        device port. If not provided, find a new available port as host
-        port.
-
-    Raises:
-      AppRestoreConnectionError: When the app was not able to be started.
-    """
-    self.host_port = port or utils.get_available_host_port()
-    self._adb.forward(['tcp:%d' % self.host_port, 'tcp:%d' % self.device_port])
-    try:
-      self.connect()
-    except Exception:
-      # Log the original error and raise AppRestoreConnectionError.
-      self.log.exception('Failed to re-connect to app.')
-      raise jsonrpc_client_base.AppRestoreConnectionError(
-          self._ad,
-          (
-              'Failed to restore app connection for %s at host port %s, '
-              'device port %s'
-          )
-          % (self.package, self.host_port, self.device_port),
-      )
-
-    # Because the previous connection was lost, update self._proc
-    self._proc = None
-    self._restore_event_client()
-
-  def stop_app(self):
-    # Kill the pending 'adb shell am instrument -w' process if there is one.
-    # Although killing the snippet apk would abort this process 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.
-    self.log.debug('Stopping snippet apk %s', self.package)
-    # Close the socket connection.
-    self.disconnect()
-    if self._proc:
-      utils.stop_standing_subprocess(self._proc)
-      self._proc = None
-    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 errors.DeviceError(
-          self._ad, 'Failed to stop existing apk. Unexpected output: %s' % out
-      )
-
-    self._stop_event_client()
-
-  def _start_event_client(self):
-    """Overrides superclass."""
-    event_client = SnippetClient(package=self.package, ad=self._ad)
-    event_client.host_port = self.host_port
-    event_client.device_port = self.device_port
-    event_client.connect(self.uid, jsonrpc_client_base.JsonRpcCommand.CONTINUE)
-    return event_client
-
-  def _stop_event_client(self):
-    """Releases all the resources acquired in `_start_event_client`."""
-    if self._event_client:
-      self._event_client.close_socket_connection()
-      # Without cleaning host_port of event_client, the event client will try to
-      # stop the port forwarding when deconstructed, which should only be
-      # stopped by the corresponding snippet client.
-      self._event_client.host_port = None
-      self._event_client.device_port = None
-      self._event_client = None
-
-  def _restore_event_client(self):
-    """Restores previously created event client."""
-    if not self._event_client:
-      self._event_client = self._start_event_client()
-      return
-    self._event_client.host_port = self.host_port
-    self._event_client.device_port = self.device_port
-    self._event_client.connect()
-
-  def _check_app_installed(self):
-    # Check 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('^package:%s$' % self.package, out):
-      raise AppStartPreCheckError(
-          self._ad, f'{self.package} is not installed for user {self.user_id}.'
-      )
-    # Check 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 AppStartPreCheckError(
-          self._ad, f'{self.package} is installed, but it is not instrumented.'
-      )
-    match = re.search(
-        r'^instrumentation:(.*)\/(.*) \(target=(.*)\)$', matched_out[0]
-    )
-    target_name = match.group(3)
-    # Check 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('^package:%s$' % target_name, out):
-        raise AppStartPreCheckError(
-            self._ad,
-            f'Instrumentation target {target_name} is not installed for user '
-            f'{self.user_id}.',
-        )
-
-  def _do_start_app(self, launch_cmd):
-    adb_cmd = [adb.ADB]
-    if self._adb.serial:
-      adb_cmd += ['-s', self._adb.serial]
-    adb_cmd += ['shell', launch_cmd]
-    return utils.start_standing_subprocess(adb_cmd, shell=False)
-
-  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:
-      (str) Next line of snippet-related instrumentation output, stripped.
-
-    Raises:
-      jsonrpc_client_base.AppStartError: If EOF is reached without any
-        protocol lines being read.
-    """
-    while True:
-      line = self._proc.stdout.readline().decode('utf-8')
-      if not line:
-        raise jsonrpc_client_base.AppStartError(
-            self._ad, 'Unexpected EOF waiting for app 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 _get_persist_command(self):
-    """Check availability and return path of 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 performs USB disconnection may fail',
-        _SETSID_COMMAND,
-        _NOHUP_COMMAND,
-    )
-    return ''
-
-  def help(self, print_output=True):
-    """Calls the help RPC, which returns the list of RPC calls available.
-
-    This RPC should normally be used in an interactive console environment
-    where the output should be printed instead of returned. Otherwise,
-    newlines will be escaped, which will make the output difficult to read.
-
-    Args:
-      print_output: A bool for whether the output should be printed.
-
-    Returns:
-      A str containing the help output otherwise None if print_output
-        wasn't set.
-    """
-    help_text = self._rpc('help')
-    if print_output:
-      print(help_text)
-    else:
-      return help_text
diff --git a/mobly/controllers/android_device_lib/snippet_client_v2.py b/mobly/controllers/android_device_lib/snippet_client_v2.py
index 1b9c9ea..daa3722 100644
--- a/mobly/controllers/android_device_lib/snippet_client_v2.py
+++ b/mobly/controllers/android_device_lib/snippet_client_v2.py
@@ -116,12 +116,15 @@
       is because Mobly snippet runner changes the subsequent instrumentation
       process.
     user_id: The user id under which to launch the snippet process.
+    am_cmd_prefix: An optional prefix string prepended directly before the `am`
+      command (e.g. `'CLASSPATH=/data/local/tmp/app.apk'` or `'env ...'`).
   """
 
-  am_instrument_options: Dict[str, str] = dataclasses.field(
+  am_instrument_options: dict[str, str] = dataclasses.field(
       default_factory=dict
   )
-  user_id: Union[int, None] = None
+  user_id: int | None = None
+  am_cmd_prefix: str | None = None
 
 
 class ConnectionHandshakeCommand(enum.Enum):
@@ -318,6 +321,8 @@
         snippet_package=self.package,
         instrument_options=option_str,
     )
+    if self._config.am_cmd_prefix:
+      cmd = f'{self._config.am_cmd_prefix.strip()} {cmd}'
     self._proc = self._run_adb_cmd(cmd)
 
     # Check protocol version and get the device port
diff --git a/mobly/controllers/iperf_server.py b/mobly/controllers/iperf_server.py
index e99b298..51101bd 100644
--- a/mobly/controllers/iperf_server.py
+++ b/mobly/controllers/iperf_server.py
@@ -115,8 +115,8 @@
 
   def __init__(self, port, log_path):
     self.port = port
-    self.log_path = os.path.join(log_path, 'iPerf{}'.format(self.port))
-    self.iperf_str = 'iperf3 -s -J -p {}'.format(port)
+    self.log_path = os.path.join(log_path, f'iPerf{self.port}')
+    self.iperf_str = f'iperf3 -s -J -p {port}'
     self.iperf_process = None
     self.log_files = []
     self.started = False
@@ -135,11 +135,9 @@
     utils.create_dir(self.log_path)
     if tag:
       tag = tag + ','
-    out_file_name = 'IPerfServer,{},{}{}.log'.format(
-        self.port, tag, len(self.log_files)
-    )
+    out_file_name = f'IPerfServer,{self.port},{tag}{len(self.log_files)}.log'
     full_out_path = os.path.join(self.log_path, out_file_name)
-    cmd = '%s %s > %s' % (self.iperf_str, extra_args, full_out_path)
+    cmd = f'{self.iperf_str} {extra_args} > {full_out_path}'
     self.iperf_process = utils.start_standing_subprocess(cmd, shell=True)
     self.log_files.append(full_out_path)
     self.started = True
diff --git a/mobly/controllers/sniffer.py b/mobly/controllers/sniffer.py
index e1f3460..3ae6311 100644
--- a/mobly/controllers/sniffer.py
+++ b/mobly/controllers/sniffer.py
@@ -38,8 +38,8 @@
     sniffer_subtype = c["SubType"]
     interface = c["Interface"]
     base_configs = c["BaseConfigs"]
-    module_name = "mobly.controllers.sniffer_lib.{}.{}".format(
-        sniffer_type, sniffer_subtype
+    module_name = (
+        f"mobly.controllers.sniffer_lib.{sniffer_type}.{sniffer_subtype}"
     )
     module = importlib.import_module(module_name)
     objs.append(
diff --git a/mobly/controllers/sniffer_lib/local/local_base.py b/mobly/controllers/sniffer_lib/local/local_base.py
index 0b2f6bf..730aeec 100644
--- a/mobly/controllers/sniffer_lib/local/local_base.py
+++ b/mobly/controllers/sniffer_lib/local/local_base.py
@@ -122,11 +122,11 @@
           "Trying to start a sniff while another is still running!"
       )
     capture_dir = os.path.join(
-        self._logger.log_path, "Sniffer-{}".format(self._interface)
+        self._logger.log_path, f"Sniffer-{self._interface}"
     )
     os.makedirs(capture_dir, exist_ok=True)
     self._capture_file_path = os.path.join(
-        capture_dir, "capture_{}.pcap".format(logger.get_log_file_timestamp())
+        capture_dir, f"capture_{logger.get_log_file_timestamp()}.pcap"
     )
 
     self._pre_capture_config(override_configs)
diff --git a/mobly/controllers/sniffer_lib/local/tcpdump.py b/mobly/controllers/sniffer_lib/local/tcpdump.py
index 4f54cdb..493438a 100644
--- a/mobly/controllers/sniffer_lib/local/tcpdump.py
+++ b/mobly/controllers/sniffer_lib/local/tcpdump.py
@@ -26,7 +26,7 @@
 
     super().__init__(config_path, logger, base_configs=base_configs)
 
-    self._executable_path = shutil.which("tcpdump")
+    self._executable_path = shutil.which('tcpdump')
     if self._executable_path is None:
       raise sniffer.SnifferError(
           "Cannot find a path to the 'tcpdump' executable"
@@ -34,20 +34,18 @@
 
   def get_descriptor(self):
     """See base class documentation"""
-    return "local-tcpdump-{}".format(self._interface)
+    return f'local-tcpdump-{self._interface}'
 
   def get_subtype(self):
     """See base class documentation"""
-    return "tcpdump"
+    return 'tcpdump'
 
   def _get_command_line(
       self, additional_args=None, duration=None, packet_count=None
   ):
-    cmd = "{} -i {} -w {}".format(
-        self._executable_path, self._interface, self._temp_capture_file_path
-    )
+    cmd = f'{self._executable_path} -i {self._interface} -w {self._temp_capture_file_path}'
     if packet_count is not None:
-      cmd = "{} -c {}".format(cmd, packet_count)
+      cmd = f'{cmd} -c {packet_count}'
     if additional_args is not None:
-      cmd = "{} {}".format(cmd, additional_args)
+      cmd = f'{cmd} {additional_args}'
     return cmd
diff --git a/mobly/controllers/sniffer_lib/local/tshark.py b/mobly/controllers/sniffer_lib/local/tshark.py
index e48b1c6..695a8b6 100644
--- a/mobly/controllers/sniffer_lib/local/tshark.py
+++ b/mobly/controllers/sniffer_lib/local/tshark.py
@@ -26,8 +26,8 @@
 
     super().__init__(config_path, logger, base_configs=base_configs)
 
-    self._executable_path = shutil.which("tshark") or shutil.which(
-        "/usr/local/bin/tshark"
+    self._executable_path = shutil.which('tshark') or shutil.which(
+        '/usr/local/bin/tshark'
     )
     if self._executable_path is None:
       raise sniffer.SnifferError(
@@ -37,22 +37,20 @@
 
   def get_descriptor(self):
     """See base class documentation"""
-    return "local-tshark-{}-ch{}".format(self._interface)
+    return f'local-tshark-{self._interface}'
 
   def get_subtype(self):
     """See base class documentation"""
-    return "tshark"
+    return 'tshark'
 
   def _get_command_line(
       self, additional_args=None, duration=None, packet_count=None
   ):
-    cmd = "{} -i {} -w {}".format(
-        self._executable_path, self._interface, self._temp_capture_file_path
-    )
+    cmd = f'{self._executable_path} -i {self._interface} -w {self._temp_capture_file_path}'
     if duration is not None:
-      cmd = "{} -a duration:{}".format(cmd, duration)
+      cmd = f'{cmd} -a duration:{duration}'
     if packet_count is not None:
-      cmd = "{} -c {}".format(cmd, packet_count)
+      cmd = f'{cmd} -c {packet_count}'
     if additional_args is not None:
-      cmd = "{} {}".format(cmd, additional_args)
+      cmd = f'{cmd} {additional_args}'
     return cmd
diff --git a/mobly/expects.py b/mobly/expects.py
index ad7cb8a..c9c4e99 100644
--- a/mobly/expects.py
+++ b/mobly/expects.py
@@ -13,6 +13,7 @@
 # limitations under the License.
 
 import contextlib
+import functools
 import logging
 import time
 
@@ -135,31 +136,65 @@
     recorder.add_error(e)
 
 
-@contextlib.contextmanager
-def expect_no_raises(message=None, extras=None):
-  """Expects no exception is raised in a context.
+class expect_no_raises(contextlib.ContextDecorator):
+  """Expects no exception is raised in a context or a decorated function.
 
   If the expectation is not met, the test is marked as fail after its
   execution finishes.
 
-  A default message is added to the exception `details`.
+  Can be used as a context manager:
+    with expects.expect_no_raises(message='Custom message'):
+      do_something()
+
+  Or as a function decorator:
+    @expects.expect_no_raises(message='Custom message')
+    def helper_function(arg):
+      do_something(arg)
+
+    @expects.expect_no_raises
+    def bare_decorated_function():
+      do_something()
 
   Args:
-    message: string, custom message to add to exception's `details`.
+    message: An optional custom message to add to the exception's `details`.
     extras: An optional field for extra information to be included in test
       result.
   """
-  try:
-    yield
-  except Exception as e:
-    e_record = records.ExceptionRecord(e)
-    if extras:
-      e_record.extras = extras
-    msg = message or 'Got an unexpected exception'
-    details = '%s: %s' % (msg, e_record.details)
-    logging.exception(details)
-    e_record.details = details
-    recorder.add_error(e_record)
+
+  def __new__(cls, *args, **kwargs):
+    if len(args) == 1 and callable(args[0]) and not kwargs:
+      # Used as bare decorator: @expects.expect_no_raises
+      func = args[0]
+      instance = super().__new__(cls)
+      instance.__init__()
+
+      @functools.wraps(func)
+      def wrapped(*f_args, **f_kwargs):
+        with instance:
+          return func(*f_args, **f_kwargs)
+
+      return wrapped
+    return super().__new__(cls)
+
+  def __init__(self, message=None, extras=None):
+    self._message = message
+    self._extras = extras
+
+  def __enter__(self):
+    return self
+
+  def __exit__(self, exc_type, exc_val, exc_tb):
+    if exc_type is not None and issubclass(exc_type, Exception):
+      e_record = records.ExceptionRecord(exc_val)
+      if self._extras:
+        e_record.extras = self._extras
+      msg = self._message or 'Got an unexpected exception'
+      details = '%s: %s' % (msg, e_record.details)
+      logging.exception(details)
+      e_record.details = details
+      recorder.add_error(e_record)
+      return True
+    return False
 
 
 recorder = _ExpectErrorRecorder(DEFAULT_TEST_RESULT_RECORD)
diff --git a/mobly/logger.py b/mobly/logger.py
index b792cae..571d608 100644
--- a/mobly/logger.py
+++ b/mobly/logger.py
@@ -17,7 +17,7 @@
 import os
 import re
 import sys
-from typing import Any, MutableMapping, Tuple
+from typing import Any, MutableMapping
 
 from mobly import records
 from mobly import utils
@@ -117,9 +117,7 @@
 
   Args:
     epoch_time: integer, an epoch timestamp in ms.
-    time_zone: instance of tzinfo, time zone information.
-      Using pytz rather than python 3.2 time_zone implementation for
-      python 2 compatibility reasons.
+    time_zone: instance of datetime.tzinfo, time zone information.
 
   Returns:
     A string that is the corresponding timestamp in log line timestamp
@@ -127,7 +125,7 @@
   """
   s, ms = divmod(epoch_time, 1000)
   d = datetime.datetime.fromtimestamp(s, tz=time_zone)
-  return d.strftime('%m-%d %H:%M:%S.') + str(ms)
+  return d.strftime('%m-%d %H:%M:%S.') + f'{ms:03d}'
 
 
 def get_log_line_timestamp(delta=None):
@@ -393,7 +391,7 @@
   """
 
   _KWARGS_TYPE = MutableMapping[str, Any]
-  _PROCESS_RETURN_TYPE = Tuple[str, _KWARGS_TYPE]
+  _PROCESS_RETURN_TYPE = tuple[str, _KWARGS_TYPE]
 
   # The key of log_preifx item in the dict self.extra
   EXTRA_KEY_LOG_PREFIX: str = 'log_prefix'
diff --git a/mobly/suite_runner.py b/mobly/suite_runner.py
index 15f6bc1..7a11e10 100644
--- a/mobly/suite_runner.py
+++ b/mobly/suite_runner.py
@@ -367,7 +367,7 @@
         runner.run()
         ok = runner.results.is_all_pass
       except signals.TestAbortAll:
-        pass
+        ok = runner.results.is_all_pass
     finally:
       suite.teardown_suite()
       suite_record.suite_end()
@@ -421,7 +421,7 @@
         runner.run()
         ok = runner.results.is_all_pass and ok
       except signals.TestAbortAll:
-        pass
+        ok = runner.results.is_all_pass and ok
       except Exception:
         logging.exception('Exception when executing %s.', config.testbed_name)
         ok = False
diff --git a/mobly/utils.py b/mobly/utils.py
index 0bea52d..0342c6c 100644
--- a/mobly/utils.py
+++ b/mobly/utils.py
@@ -29,7 +29,7 @@
 import subprocess
 import time
 import traceback
-from typing import Literal, Tuple, overload
+from typing import Literal, overload
 
 import portpicker
 
@@ -396,8 +396,9 @@
     timeout=...,
     cwd=...,
     env=...,
-    universal_newlines: Literal[False] = ...,
-) -> Tuple[int, bytes, bytes]:
+    text: Literal[False] = ...,
+    universal_newlines: Literal[False] | None = ...,
+) -> tuple[int, bytes, bytes]:
   ...
 
 
@@ -410,8 +411,9 @@
     timeout=...,
     cwd=...,
     env=...,
-    universal_newlines: Literal[True] = ...,
-) -> Tuple[int, str, str]:
+    text: Literal[True] = ...,
+    universal_newlines: Literal[True] | None = ...,
+) -> tuple[int, str, str]:
   ...
 
 
@@ -423,7 +425,8 @@
     timeout=None,
     cwd=None,
     env=None,
-    universal_newlines=False,
+    text=False,
+    universal_newlines=None,
 ):
   """Runs a command in a subprocess.
 
@@ -451,8 +454,9 @@
     env: dict, a mapping that defines the environment variables for the
       new process. Default behavior is inheriting the current process'
       environment.
-    universal_newlines: bool, True to open file objects in text mode, False in
+    text: bool, True to open file objects in text mode, False in
       binary mode.
+    universal_newlines: bool, legacy alias for `text`.
 
   Returns:
     A 3-tuple of the consisting of the return code, the std output, and the
@@ -461,6 +465,8 @@
   Raises:
     subprocess.TimeoutExpired: The command timed out.
   """
+  if universal_newlines is not None:
+    text = universal_newlines
   if stdout is None:
     stdout = subprocess.PIPE
   if stderr is None:
@@ -472,7 +478,7 @@
       shell=shell,
       cwd=cwd,
       env=env,
-      text=universal_newlines,  # "text" is introdcued in Python 3.7.
+      text=text,
   )
   out, err = None, None
   try:
@@ -600,7 +606,8 @@
 def get_available_host_port():
   """Gets a host port number available for adb forward.
 
-  DEPRECATED: This method is unreliable. Pass `tcp:0` to adb forward instead.
+  .. deprecated:: 1.12
+     This method is unreliable. Pass ``tcp:0`` to adb forward instead.
 
   Returns:
     An integer representing a port number on the host available for adb
diff --git a/pyproject.toml b/pyproject.toml
index 1a0c929..476302f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@
 
 [project]
 name = "mobly"
-version = "1.13"
+version = "1.13.1"
 description = "Automation framework for special end-to-end test cases"
 requires-python = ">=3.11"
 dependencies = [ "portpicker", "pywin32; platform_system == \"Windows\"", "pyyaml",]
@@ -18,10 +18,10 @@
 
 [project.urls]
 Homepage = "https://github.com/google/mobly"
-Download = "https://github.com/google/mobly/tarball/1.13"
+Download = "https://github.com/google/mobly/tarball/1.13.1"
 
 [project.optional-dependencies]
-testing = [ "mock", "pytest", "pytz",]
+testing = [ "pytest",]
 
 [tool.setuptools]
 include-package-data = false
diff --git a/tests/lib/dynamic_repeat_retry_test.py b/tests/lib/dynamic_repeat_retry_test.py
new file mode 100644
index 0000000..7d0b04f
--- /dev/null
+++ b/tests/lib/dynamic_repeat_retry_test.py
@@ -0,0 +1,47 @@
+# Copyright 2026 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.
+
+from mobly import asserts
+from mobly import base_test
+from mobly import test_runner
+from tests.lib import mock_controller
+
+
+class DynamicTest(base_test.BaseTestClass):
+  """Test class using @repeat and @retry with dynamic user_params keys."""
+
+  repeat_counter = 0
+  retry_counter = 0
+
+  def setup_class(self):
+    self.register_controller(mock_controller)
+    type(self).repeat_counter = 0
+    type(self).retry_counter = 0
+
+  @base_test.repeat(count=2, count_key='repeat_key')
+  def test_repeat(self):
+    type(self).repeat_counter += 1
+    asserts.assert_true(True, 'Repeat iteration succeeded')
+
+  @base_test.retry(max_count=2, max_count_key='retry_key')
+  def test_retry(self):
+    type(self).retry_counter += 1
+    # Succeeds on 3rd attempt if retry limit is >= 3.
+    if type(self).retry_counter < 3:
+      asserts.fail(f'Retry attempt {type(self).retry_counter} failed')
+    asserts.assert_true(True, 'Retry attempt succeeded')
+
+
+if __name__ == '__main__':
+  test_runner.main()
diff --git a/tests/lib/mock_android_device.py b/tests/lib/mock_android_device.py
index 923c8c8..713286a 100755
--- a/tests/lib/mock_android_device.py
+++ b/tests/lib/mock_android_device.py
@@ -169,7 +169,7 @@
 
     def adb_call(*args, **kwargs):
       arg_str = ' '.join(str(elem) for elem in args)
-      return arg_str
+      return bytes(arg_str, 'utf-8')
 
     return adb_call
 
diff --git a/tests/mobly/base_test_test.py b/tests/mobly/base_test_test.py
index 81d3f7e..2a19494 100755
--- a/tests/mobly/base_test_test.py
+++ b/tests/mobly/base_test_test.py
@@ -1344,7 +1344,10 @@
         'Error 0, Executed 1, Failed 1, Passed 0, Requested 3, Skipped 2',
     )
 
-  def test_abort_all_in_on_fail_from_setup_class(self):
+  @mock.patch('mobly.records.TestSummaryWriter.dump')
+  def test_abort_all_in_on_fail_from_setup_class(self, mock_dump):
+    on_fail_record_state = {}
+
     class MockBaseTest(base_test.BaseTestClass):
 
       def setup_class(self):
@@ -1360,6 +1363,8 @@
         never_call()
 
       def on_fail(self, record):
+        on_fail_record_state['end_time'] = record.end_time
+        on_fail_record_state['details'] = record.details
         asserts.abort_all(MSG_EXPECTED_EXCEPTION)
 
     bt_cls = MockBaseTest(self.mock_test_cls_configs)
@@ -1367,6 +1372,8 @@
         signals.TestAbortAll, MSG_EXPECTED_EXCEPTION
     ) as context:
       bt_cls.run(test_names=['test_1', 'test_2', 'test_3'])
+    self.assertIsNotNone(on_fail_record_state['end_time'])
+    self.assertEqual(on_fail_record_state['details'], MSG_UNEXPECTED_EXCEPTION)
     setup_class_record = bt_cls.results.error[0]
     self.assertEqual(setup_class_record.test_name, 'setup_class')
     self.assertTrue(hasattr(context.exception, 'results'))
@@ -1374,6 +1381,50 @@
         bt_cls.results.summary_str(),
         'Error 1, Executed 0, Failed 0, Passed 0, Requested 3, Skipped 3',
     )
+    setup_class_dict = mock_dump.call_args_list[1][0][0]
+    self.assertEqual(setup_class_dict['Test Name'], 'setup_class')
+    self.assertEqual(setup_class_dict['Result'], 'ERROR')
+
+  @mock.patch('mobly.records.TestSummaryWriter.dump')
+  def test_abort_all_in_on_fail_from_setup_class_with_expects(self, mock_dump):
+    on_fail_record_state = {}
+
+    class MockBaseTest(base_test.BaseTestClass):
+
+      def setup_class(self):
+        expects.expect_true(False, MSG_UNEXPECTED_EXCEPTION)
+
+      def test_1(self):
+        never_call()
+
+      def test_2(self):
+        never_call()
+
+      def test_3(self):
+        never_call()
+
+      def on_fail(self, record):
+        on_fail_record_state['end_time'] = record.end_time
+        on_fail_record_state['details'] = record.details
+        asserts.abort_all(MSG_EXPECTED_EXCEPTION)
+
+    bt_cls = MockBaseTest(self.mock_test_cls_configs)
+    with self.assertRaisesRegex(
+        signals.TestAbortAll, MSG_EXPECTED_EXCEPTION
+    ) as context:
+      bt_cls.run(test_names=['test_1', 'test_2', 'test_3'])
+    self.assertIsNotNone(on_fail_record_state['end_time'])
+    self.assertEqual(on_fail_record_state['details'], MSG_UNEXPECTED_EXCEPTION)
+    setup_class_record = bt_cls.results.error[0]
+    self.assertEqual(setup_class_record.test_name, 'setup_class')
+    self.assertTrue(hasattr(context.exception, 'results'))
+    self.assertEqual(
+        bt_cls.results.summary_str(),
+        'Error 1, Executed 0, Failed 0, Passed 0, Requested 3, Skipped 3',
+    )
+    setup_class_dict = mock_dump.call_args_list[1][0][0]
+    self.assertEqual(setup_class_dict['Test Name'], 'setup_class')
+    self.assertEqual(setup_class_dict['Result'], 'ERROR')
 
   def test_exception_chain_unrolled_in_test_execution(self):
     class MockBaseTest(base_test.BaseTestClass):
@@ -1876,6 +1927,7 @@
   def test_expect_in_setup_class(self, mock_dump):
     must_call = mock.Mock()
     must_call2 = mock.Mock()
+    on_fail_record_state = {}
 
     class MockBaseTest(base_test.BaseTestClass):
 
@@ -1887,12 +1939,16 @@
         pass
 
       def on_fail(self, record):
+        on_fail_record_state['end_time'] = record.end_time
+        on_fail_record_state['details'] = record.details
         must_call2('on_fail')
 
     bt_cls = MockBaseTest(self.mock_test_cls_configs)
     bt_cls.run()
     must_call.assert_called_once_with('ha')
     must_call2.assert_called_once_with('on_fail')
+    self.assertIsNotNone(on_fail_record_state['end_time'])
+    self.assertEqual(on_fail_record_state['details'], MSG_EXPECTED_EXCEPTION)
     actual_record = bt_cls.results.error[0]
     self.assertEqual(actual_record.test_name, 'setup_class')
     self.assertEqual(actual_record.details, MSG_EXPECTED_EXCEPTION)
@@ -2522,7 +2578,7 @@
         ValueError,
         re.escape(
             'The `max_consecutive_error` (4) for `repeat` must be '
-            'smaller than `count` (3).'
+            '<= `count` (3).'
         ),
     ):
 
@@ -2664,6 +2720,38 @@
     for i, record in enumerate(bt_cls.results.passed):
       self.assertEqual(record.test_name, f'test_something_{i}')
 
+  def test_expect_no_raises_decorator_on_test_method(self):
+    class MockBaseTest(base_test.BaseTestClass):
+
+      @expects.expect_no_raises(message='Step 1 failed')
+      def test_step(self):
+        raise ValueError('Expected failure in step')
+
+    bt_cls = MockBaseTest(self.mock_test_cls_configs)
+    bt_cls.run()
+    self.assertEqual(1, len(bt_cls.results.failed))
+    self.assertEqual(1, len(bt_cls.results.executed))
+    record = bt_cls.results.failed[0]
+    self.assertIn('Step 1 failed', record.details)
+    self.assertIn('Expected failure in step', record.details)
+
+  def test_expect_no_raises_decorator_on_helper_method(self):
+    class MockBaseTest(base_test.BaseTestClass):
+
+      @expects.expect_no_raises
+      def helper_func(self):
+        raise RuntimeError('Helper failed')
+
+      def test_method(self):
+        self.helper_func()
+
+    bt_cls = MockBaseTest(self.mock_test_cls_configs)
+    bt_cls.run()
+    self.assertEqual(1, len(bt_cls.results.failed))
+    self.assertEqual(1, len(bt_cls.results.executed))
+    record = bt_cls.results.failed[0]
+    self.assertIn('Helper failed', record.details)
+
   def test_repeat_with_consec_error_does_not_abort_repeat(self):
     repeat_count = 5
     max_consec_error = 2
@@ -2689,6 +2777,245 @@
     self.assertEqual(repeat_count, len(bt_cls.results.executed))
     self.assertEqual(3, len(bt_cls.results.error))
 
+  def test_repeat_with_user_param_count_key(self):
+    mock_action_1 = mock.MagicMock()
+    mock_action_2 = mock.MagicMock()
+
+    class MockBaseTest(base_test.BaseTestClass):
+
+      @base_test.repeat(count=2, count_key='repeat_key_1')
+      def test_case_1(self):
+        mock_action_1()
+
+      @base_test.repeat(count=2, count_key='repeat_key_2')
+      def test_case_2(self):
+        mock_action_2()
+
+    # When repeat_key_1 is 3 and repeat_key_2 is 5, both override their default (2).
+    configs = copy.deepcopy(self.mock_test_cls_configs)
+    configs.user_params['repeat_key_1'] = 3
+    configs.user_params['repeat_key_2'] = 5
+    bt_cls = MockBaseTest(configs)
+    bt_cls.run()
+    self.assertEqual(8, len(bt_cls.results.passed))
+    self.assertEqual(3, mock_action_1.call_count)
+    self.assertEqual(5, mock_action_2.call_count)
+
+  def test_repeat_with_user_param_count_key_not_provided_uses_default(self):
+    mock_action_1 = mock.MagicMock()
+    mock_action_2 = mock.MagicMock()
+
+    class MockBaseTest(base_test.BaseTestClass):
+
+      @base_test.repeat(count=3, count_key='repeat_key_1')
+      def test_case_1(self):
+        mock_action_1()
+
+      @base_test.repeat(count=4, count_key='repeat_key_2')
+      def test_case_2(self):
+        mock_action_2()
+
+    # When neither key is in user_params, both use their respective defaults (3 and 4).
+    bt_cls = MockBaseTest(self.mock_test_cls_configs)
+    bt_cls.run()
+    self.assertEqual(7, len(bt_cls.results.passed))
+    self.assertEqual(3, mock_action_1.call_count)
+    self.assertEqual(4, mock_action_2.call_count)
+
+  def test_repeat_with_user_param_max_consecutive_error_key(self):
+    mock_action = mock.MagicMock(
+        side_effect=[Exception('Error 1'), Exception('Error 2'), None, None]
+    )
+
+    class MockBaseTest(base_test.BaseTestClass):
+
+      @base_test.repeat(
+          count=4,
+          max_consecutive_error=3,
+          max_consecutive_error_key='custom_consec_err',
+      )
+      def test_something(self):
+        mock_action()
+
+    # Overriding max_consecutive_error to 2 aborts after 2 consecutive errors.
+    configs = copy.deepcopy(self.mock_test_cls_configs)
+    configs.user_params['custom_consec_err'] = 2
+    bt_cls = MockBaseTest(configs)
+    bt_cls.run()
+    self.assertEqual(2, len(bt_cls.results.executed))
+    self.assertEqual(2, len(bt_cls.results.error))
+
+  def test_repeat_with_user_param_invalid_integer(self):
+    class MockBaseTest(base_test.BaseTestClass):
+
+      @base_test.repeat(count=2, count_key='custom_repeat')
+      def test_something(self):
+        pass
+
+    configs = copy.deepcopy(self.mock_test_cls_configs)
+    configs.user_params['custom_repeat'] = 'not_a_number'
+    bt_cls = MockBaseTest(configs)
+    with self.assertRaisesRegex(
+        ValueError, 'Invalid integer in user_params\\["custom_repeat"\\]'
+    ):
+      bt_cls.run()
+
+  def test_repeat_with_user_param_max_consec_error_larger_than_count(self):
+    class MockBaseTest(base_test.BaseTestClass):
+
+      @base_test.repeat(
+          count=2,
+          count_key='custom_repeat',
+          max_consecutive_error=1,
+          max_consecutive_error_key='custom_consec',
+      )
+      def test_something(self):
+        pass
+
+    configs = copy.deepcopy(self.mock_test_cls_configs)
+    configs.user_params['custom_repeat'] = 3
+    configs.user_params['custom_consec'] = 4
+    bt_cls = MockBaseTest(configs)
+    with self.assertRaisesRegex(
+        ValueError,
+        re.escape(
+            'The `max_consecutive_error` (4) for `repeat` must be <= `count` '
+            '(3) for test "test_something".'
+        ),
+    ):
+      bt_cls.run()
+
+  def test_retry_with_user_param_max_count_key(self):
+    mock_action_1 = mock.MagicMock(
+        side_effect=[
+            Exception('Fail 1'),
+            Exception('Fail 2'),
+            Exception('Fail 3'),
+            None,
+        ]
+    )
+    mock_action_2 = mock.MagicMock(side_effect=[Exception('Fail 1'), None])
+
+    class MockBaseTest(base_test.BaseTestClass):
+
+      @base_test.retry(max_count=2, max_count_key='retry_key_1')
+      def test_case_1(self):
+        mock_action_1()
+
+      @base_test.retry(max_count=5, max_count_key='retry_key_2')
+      def test_case_2(self):
+        mock_action_2()
+
+    # Overriding retry_key_1 from 2 to 4 allows test_case_1 to pass on 4th attempt;
+    # overriding retry_key_2 from 5 to 3 allows test_case_2 to pass on 2nd attempt.
+    configs = copy.deepcopy(self.mock_test_cls_configs)
+    configs.user_params['retry_key_1'] = 4
+    configs.user_params['retry_key_2'] = 3
+    bt_cls = MockBaseTest(configs)
+    bt_cls.run()
+    self.assertTrue(bt_cls.results.is_all_pass)
+    self.assertEqual(6, len(bt_cls.results.executed))
+    self.assertEqual(2, len(bt_cls.results.passed))
+
+  def test_retry_with_user_param_max_count_key_not_provided_uses_default(self):
+    mock_action_1 = mock.MagicMock(side_effect=[Exception('Fail 1'), None])
+    mock_action_2 = mock.MagicMock(
+        side_effect=[Exception('Fail 1'), Exception('Fail 2'), None]
+    )
+
+    class MockBaseTest(base_test.BaseTestClass):
+
+      @base_test.retry(max_count=2, max_count_key='retry_key_1')
+      def test_case_1(self):
+        mock_action_1()
+
+      @base_test.retry(max_count=3, max_count_key='retry_key_2')
+      def test_case_2(self):
+        mock_action_2()
+
+    # When custom retry keys are not in user_params, default max_count values (2 and 3) are used.
+    bt_cls = MockBaseTest(self.mock_test_cls_configs)
+    bt_cls.run()
+    self.assertTrue(bt_cls.results.is_all_pass)
+    self.assertEqual(5, len(bt_cls.results.executed))
+    self.assertEqual(2, len(bt_cls.results.passed))
+
+  def test_repeat_and_retry_with_dynamic_user_params(self):
+    mock_action_repeat = mock.MagicMock()
+    mock_action_retry = mock.MagicMock(
+        side_effect=[Exception('Fail 1'), Exception('Fail 2'), None]
+    )
+
+    class MockBaseTest(base_test.BaseTestClass):
+
+      @base_test.repeat(count=2, count_key='repeat_count_key')
+      def test_repeat(self):
+        mock_action_repeat()
+
+      @base_test.retry(max_count=2, max_count_key='retry_count_key')
+      def test_retry(self):
+        mock_action_retry()
+
+    configs = copy.deepcopy(self.mock_test_cls_configs)
+    configs.user_params['repeat_count_key'] = 4
+    configs.user_params['retry_count_key'] = 3
+    bt_cls = MockBaseTest(configs)
+    bt_cls.run()
+
+    self.assertTrue(bt_cls.results.is_all_pass)
+    # 4 repeat executions + 3 retry executions = 7 executions in total
+    self.assertEqual(7, len(bt_cls.results.executed))
+    self.assertEqual(5, len(bt_cls.results.passed))
+    self.assertEqual(4, mock_action_repeat.call_count)
+    self.assertEqual(3, mock_action_retry.call_count)
+
+  def test_repeat_with_user_param_count_less_than_two(self):
+    class MockBaseTest(base_test.BaseTestClass):
+
+      @base_test.repeat(count=2, count_key='custom_repeat')
+      def test_something(self):
+        pass
+
+    configs = copy.deepcopy(self.mock_test_cls_configs)
+    configs.user_params['custom_repeat'] = 1
+    bt_cls = MockBaseTest(configs)
+    with self.assertRaisesRegex(
+        ValueError,
+        'The `count` for `repeat` must be larger than 1, got "1" for test "test_something".',
+    ):
+      bt_cls.run()
+
+  def test_retry_with_user_param_count_less_than_two(self):
+    class MockBaseTest(base_test.BaseTestClass):
+
+      @base_test.retry(max_count=2, max_count_key='custom_retry')
+      def test_something(self):
+        pass
+
+    configs = copy.deepcopy(self.mock_test_cls_configs)
+    configs.user_params['custom_retry'] = 0
+    bt_cls = MockBaseTest(configs)
+    with self.assertRaisesRegex(
+        ValueError,
+        'The `max_count` for `retry` must be larger than 1, got "0" for test "test_something".',
+    ):
+      bt_cls.run()
+
+  def test_retry_with_user_param_invalid_integer(self):
+    class MockBaseTest(base_test.BaseTestClass):
+
+      @base_test.retry(max_count=2, max_count_key='custom_retry')
+      def test_something(self):
+        pass
+
+    configs = copy.deepcopy(self.mock_test_cls_configs)
+    configs.user_params['custom_retry'] = 'not_a_number'
+    bt_cls = MockBaseTest(configs)
+    with self.assertRaisesRegex(
+        ValueError, 'Invalid integer in user_params\\["custom_retry"\\]'
+    ):
+      bt_cls.run()
+
   def test_retry_invalid_count(self):
     with self.assertRaisesRegex(
         ValueError,
@@ -2920,7 +3247,9 @@
       except RecoverableError:
         pass
 
-    logging_patch.debug.assert_called_with('[TestClass]#stage <<< END <<<')
+    logging_patch.debug.assert_called_with(
+        base_test.TEST_STAGE_END_LOG_TEMPLATE, 'TestClass', 'stage'
+    )
 
 
 if __name__ == '__main__':
diff --git a/tests/mobly/controllers/android_device_lib/adb_test.py b/tests/mobly/controllers/android_device_lib/adb_test.py
index 7b01feb..31d3ff8 100755
--- a/tests/mobly/controllers/android_device_lib/adb_test.py
+++ b/tests/mobly/controllers/android_device_lib/adb_test.py
@@ -13,7 +13,9 @@
 # limitations under the License.
 
 import collections
+import copy
 import io
+import pickle
 import subprocess
 import unittest
 from unittest import mock
@@ -998,6 +1000,54 @@
     )
     self.assertEqual(user_id, 123)
 
+  def test_adberror_deepcopy_and_pickle(self):
+    err = adb.AdbError(['adb', 'devices'], b'stdout', b'stderr', 1, 'serial123')
+    err_copy = copy.deepcopy(err)
+    self.assertIsNot(err, err_copy)
+    self.assertIsNot(err.cmd, err_copy.cmd)
+    self.assertEqual(err.cmd, err_copy.cmd)
+    self.assertEqual(err.stdout, err_copy.stdout)
+    self.assertEqual(err.stderr, err_copy.stderr)
+    self.assertEqual(err.ret_code, err_copy.ret_code)
+    self.assertEqual(err.serial, err_copy.serial)
+    self.assertEqual(str(err), str(err_copy))
+
+    # Validate deepcopy isolation by mutating the original list
+    err.cmd.append('--extra')
+    self.assertNotEqual(err.cmd, err_copy.cmd)
+
+    err_unpickled = pickle.loads(pickle.dumps(err_copy))
+    self.assertIsNot(err_copy, err_unpickled)
+    self.assertIsNot(err_copy.cmd, err_unpickled.cmd)
+    self.assertEqual(err_copy.cmd, err_unpickled.cmd)
+    self.assertEqual(err_copy.stdout, err_unpickled.stdout)
+    self.assertEqual(err_copy.stderr, err_unpickled.stderr)
+    self.assertEqual(err_copy.ret_code, err_unpickled.ret_code)
+    self.assertEqual(err_copy.serial, err_unpickled.serial)
+    self.assertEqual(str(err_copy), str(err_unpickled))
+
+  def test_adbtimeouterror_deepcopy_and_pickle(self):
+    err = adb.AdbTimeoutError(['adb', 'logcat'], 15.5, 'serial123')
+    err_copy = copy.deepcopy(err)
+    self.assertIsNot(err, err_copy)
+    self.assertIsNot(err.cmd, err_copy.cmd)
+    self.assertEqual(err.cmd, err_copy.cmd)
+    self.assertEqual(err.timeout, err_copy.timeout)
+    self.assertEqual(err.serial, err_copy.serial)
+    self.assertEqual(str(err), str(err_copy))
+
+    # Validate deepcopy isolation by mutating the original list
+    err.cmd.append('--extra')
+    self.assertNotEqual(err.cmd, err_copy.cmd)
+
+    err_unpickled = pickle.loads(pickle.dumps(err_copy))
+    self.assertIsNot(err_copy, err_unpickled)
+    self.assertIsNot(err_copy.cmd, err_unpickled.cmd)
+    self.assertEqual(err_copy.cmd, err_unpickled.cmd)
+    self.assertEqual(err_copy.timeout, err_unpickled.timeout)
+    self.assertEqual(err_copy.serial, err_unpickled.serial)
+    self.assertEqual(str(err_copy), str(err_unpickled))
+
 
 if __name__ == '__main__':
   unittest.main()
diff --git a/tests/mobly/controllers/android_device_lib/apk_utils_test.py b/tests/mobly/controllers/android_device_lib/apk_utils_test.py
index 36f2ed2..5698274 100644
--- a/tests/mobly/controllers/android_device_lib/apk_utils_test.py
+++ b/tests/mobly/controllers/android_device_lib/apk_utils_test.py
@@ -27,7 +27,7 @@
 class ApkUtilsTest(unittest.TestCase):
 
   def setUp(self):
-    super(ApkUtilsTest, self).setUp()
+    super().setUp()
     self.mock_device = mock.MagicMock()
     self.mock_device.adb.current_user_id = 0
 
diff --git a/tests/mobly/controllers/android_device_lib/services/logcat_test.py b/tests/mobly/controllers/android_device_lib/services/logcat_test.py
index 961b152..883324c 100755
--- a/tests/mobly/controllers/android_device_lib/services/logcat_test.py
+++ b/tests/mobly/controllers/android_device_lib/services/logcat_test.py
@@ -14,8 +14,11 @@
 
 import logging
 import os
+import re
 import shutil
 import tempfile
+import threading
+import time
 import unittest
 from unittest import mock
 
@@ -23,6 +26,7 @@
 from mobly import runtime_test_info
 from mobly.controllers import android_device
 from mobly.controllers.android_device_lib import adb
+from mobly.controllers.android_device_lib import logcat_processor
 from mobly.controllers.android_device_lib.services import logcat
 from tests.lib import mock_android_device
 
@@ -67,6 +71,8 @@
     0,
 )
 
+_DATE_COMMAND = ['date', r'+%Y-%m-%d\ %H:%M:%S.%3N']
+
 
 class LogcatTest(unittest.TestCase):
   """Tests for Logcat service and its integration with AndroidDevice."""
@@ -116,6 +122,7 @@
       MockAdbProxy,
   ):
     """Verifies the steps of collecting adb logcat on an AndroidDevice
+
     object, including various function calls and the expected behaviors of
     the calls.
     """
@@ -132,7 +139,7 @@
         'logcat,%s,fakemodel,123.txt' % ad.serial,
     )
     create_dir_mock.assert_called_with(os.path.dirname(expected_log_path))
-    adb_cmd = ' "adb" -s %s logcat -v threadtime -T 1  >> %s'
+    adb_cmd = ' "adb" -s %s logcat -v threadtime -T "1"  >> %s'
     start_proc_mock.assert_called_with(
         adb_cmd % (ad.serial, '"%s" ' % expected_log_path), shell=True
     )
@@ -206,7 +213,8 @@
     self.assertTrue(logcat_service._adb_logcat_process)
     create_dir_mock.assert_has_calls([mock.call('some/path')])
     expected_adb_cmd = (
-        ' "adb" -s 1 logcat -v threadtime -T 1 -a -b -c >> "some/path/log.txt" '
+        ' "adb" -s 1 logcat -v threadtime -T "1" -a -b -c >>'
+        ' "some/path/log.txt" '
     )
     start_proc_mock.assert_called_with(expected_adb_cmd, shell=True)
     self.assertEqual(logcat_service.adb_logcat_file_path, 'some/path/log.txt')
@@ -248,6 +256,44 @@
     self.assertTrue(logcat_service.is_alive)
     logcat_service.stop()
 
+  def _adb_shell_logic(self, cmd, date_result=None, raise_error=False):
+    if cmd == _DATE_COMMAND:
+      if raise_error:
+        raise adb.AdbError(cmd, b'', b'Device is disconnected', 1)
+      return date_result.encode('utf-8')
+    return b''
+
+  def _pause_and_resume_logcat_service(
+      self,
+      logcat_service,
+      date,
+      state,
+      expected_log_path,
+      ad_serial,
+      stop_proc_mock,
+      start_proc_mock,
+      clear_adb_mock,
+  ):
+    """Pauses and resumes the log collection and verifies the operations."""
+    # Pause the logcat collection at time stamp `date`.
+    state['date_result'] = date
+    logcat_service.pause()
+    self.assertFalse(logcat_service.is_alive)
+    stop_proc_mock.assert_called_with('process')
+    self.assertIsNone(logcat_service._adb_logcat_process)
+    clear_adb_mock.reset_mock()
+
+    # Resume the logcat collection, which should look back to time
+    # stamp `date`.
+    logcat_service.resume()
+    self.assertTrue(logcat_service.is_alive)
+    clear_adb_mock.assert_not_called()
+    adb_cmd = ' "adb" -s %s logcat -v threadtime -T "' + date + '"  >> %s'
+    start_proc_mock.assert_called_with(
+        adb_cmd % (ad_serial, '"%s" ' % expected_log_path), shell=True
+    )
+    self.assertIsNone(logcat_service._last_connection_time)
+
   @mock.patch(
       'mobly.controllers.android_device_lib.adb.AdbProxy',
       return_value=mock_android_device.MockAdbProxy('1'),
@@ -264,8 +310,10 @@
       'mobly.controllers.android_device_lib.services.logcat.Logcat.clear_adb_log',
       return_value=mock_android_device.MockAdbProxy('1'),
   )
+  @mock.patch('mobly.logger.get_log_file_timestamp')
   def test_pause_and_resume(
       self,
+      get_timestamp_mock,
       clear_adb_mock,
       open_logcat_mock,
       stop_proc_mock,
@@ -274,20 +322,155 @@
       FastbootProxy,
       MockAdbProxy,
   ):
+    # The test will pause and resume the log collection twice. The
+    # resume operation is supposed to request the log messages since the
+    # log collection got paused.
+
+    state = {'date_result': ''}
+    date_result_first_pause = '2026-01-14 17:01:02.342'
+    date_result_second_pause = '2026-01-14 17:03:04.789'
+
+    adb_instance = MockAdbProxy.return_value
+    adb_instance.shell = mock.MagicMock()
+    adb_instance.has_shell_command = mock.MagicMock(return_value=True)
+
+    adb_instance.shell.side_effect = (
+        lambda cmd, *args, **kwargs: self._adb_shell_logic(
+            cmd, state['date_result']
+        )
+    )
+
+    # Set up the logcat instance.
     mock_serial = '1'
     ad = android_device.AndroidDevice(serial=mock_serial)
-    logcat_service = logcat.Logcat(ad, logcat.Config(clear_log=True))
+    get_timestamp_mock.return_value = '123'
+    configs = logcat.Config(clear_log=True)
+    logcat_service = logcat.Logcat(ad, configs)
     logcat_service.start()
+    expected_log_path = os.path.join(
+        logging.log_path,
+        'AndroidDevice%s' % ad.serial,
+        'logcat,%s,fakemodel,123.txt' % ad.serial,
+    )
+
+    # We expect that the first logcat invocation happens with time "1"
+    # to include all previously generated log messages.
+    adb_cmd = ' "adb" -s %s logcat -v threadtime -T "1"  >> %s'
+    start_proc_mock.assert_called_with(
+        adb_cmd % (ad.serial, '"%s" ' % expected_log_path), shell=True
+    )
+
     clear_adb_mock.assert_called_once_with()
     self.assertTrue(logcat_service.is_alive)
+
+    # First pause / resume iteration
+    self._pause_and_resume_logcat_service(
+        logcat_service,
+        date_result_first_pause,
+        state,
+        expected_log_path,
+        ad.serial,
+        stop_proc_mock,
+        start_proc_mock,
+        clear_adb_mock,
+    )
+
+    # Second pause / resume iteration
+    self._pause_and_resume_logcat_service(
+        logcat_service,
+        date_result_second_pause,
+        state,
+        expected_log_path,
+        ad.serial,
+        stop_proc_mock,
+        start_proc_mock,
+        clear_adb_mock,
+    )
+
+    logcat_service.stop()
+
+  @mock.patch(
+      'mobly.controllers.android_device_lib.adb.AdbProxy',
+      return_value=mock_android_device.MockAdbProxy('1'),
+  )
+  @mock.patch(
+      'mobly.controllers.android_device_lib.fastboot.FastbootProxy',
+      return_value=mock_android_device.MockFastbootProxy('1'),
+  )
+  @mock.patch('mobly.utils.create_dir')
+  @mock.patch('mobly.utils.start_standing_subprocess', return_value='process')
+  @mock.patch('mobly.utils.stop_standing_subprocess')
+  @mock.patch.object(logcat.Logcat, '_open_logcat_file')
+  @mock.patch(
+      'mobly.controllers.android_device_lib.services.logcat.Logcat.clear_adb_log',
+      return_value=mock_android_device.MockAdbProxy('1'),
+  )
+  @mock.patch('mobly.logger.get_log_file_timestamp')
+  def test_pause_and_resume_while_raising_exception_when_getting_pause_time(
+      self,
+      get_timestamp_mock,
+      clear_adb_mock,
+      open_logcat_mock,
+      stop_proc_mock,
+      start_proc_mock,
+      create_dir_mock,
+      FastbootProxy,
+      MockAdbProxy,
+  ):
+    # The test will pause and resume the log collection, but it will
+    # fail to retrieve a date while pausing the log collection. The log
+    # collection is expected to resume back from the earliest time stamp
+    # "1".
+
+    adb_instance = MockAdbProxy.return_value
+    adb_instance.shell = mock.MagicMock()
+    adb_instance.has_shell_command = mock.MagicMock(return_value=True)
+
+    adb_instance.shell.side_effect = (
+        lambda cmd, *args, **kwargs: self._adb_shell_logic(
+            cmd, raise_error=True
+        )
+    )
+
+    # Set up the logcat instance.
+    mock_serial = '1'
+    ad = android_device.AndroidDevice(serial=mock_serial)
+    get_timestamp_mock.return_value = '123'
+    configs = logcat.Config(clear_log=True)
+    logcat_service = logcat.Logcat(ad, configs)
+    logcat_service.start()
+    expected_log_path = os.path.join(
+        logging.log_path,
+        'AndroidDevice%s' % ad.serial,
+        'logcat,%s,fakemodel,123.txt' % ad.serial,
+    )
+
+    # We expect that the first logcat invocation happens with time "1"
+    # to include all previously generated log messages.
+    adb_cmd = ' "adb" -s %s logcat -v threadtime -T "1"  >> %s'
+    start_proc_mock.assert_called_with(
+        adb_cmd % (ad.serial, '"%s" ' % expected_log_path), shell=True
+    )
+
+    clear_adb_mock.assert_called_once_with()
+    self.assertTrue(logcat_service.is_alive)
+
+    # Pause should fail to retrieve a valid date
     logcat_service.pause()
     self.assertFalse(logcat_service.is_alive)
     stop_proc_mock.assert_called_with('process')
     self.assertIsNone(logcat_service._adb_logcat_process)
     clear_adb_mock.reset_mock()
+
+    # Resume should start with the first logcat message again
     logcat_service.resume()
     self.assertTrue(logcat_service.is_alive)
     clear_adb_mock.assert_not_called()
+    adb_cmd = ' "adb" -s %s logcat -v threadtime -T "1"  >> %s'
+    start_proc_mock.assert_called_with(
+        adb_cmd % (ad.serial, '"%s" ' % expected_log_path), shell=True
+    )
+
     logcat_service.stop()
 
   @mock.patch(
@@ -404,6 +587,7 @@
       MockAdbProxy,
   ):
     """Verifies the steps of collecting adb logcat on an AndroidDevice
+
     object, including various function calls and the expected behaviors of
     the calls.
     """
@@ -422,7 +606,7 @@
         'logcat,%s,fakemodel,123.txt' % ad.serial,
     )
     create_dir_mock.assert_called_with(os.path.dirname(expected_log_path))
-    adb_cmd = ' "adb" -s %s logcat -v threadtime -T 1 -b radio >> %s'
+    adb_cmd = ' "adb" -s %s logcat -v threadtime -T "1" -b radio >> %s'
     start_proc_mock.assert_called_with(
         adb_cmd % (ad.serial, '"%s" ' % expected_log_path), shell=True
     )
@@ -439,6 +623,7 @@
   )
   def test_instantiation(self, MockFastboot, MockAdbProxy):
     """Verifies the AndroidDevice object's basic attributes are correctly
+
     set after instantiation.
     """
     mock_serial = 1
@@ -636,5 +821,244 @@
     logcat_service.clear_adb_log()
 
 
+SAMPLE_REALISTIC_LOGCAT = (
+    '--------- beginning of system\n'
+    '08-09 22:00:00.100  1000  1010 I SystemServer: Entered SystemServer main\n'
+    '08-09 22:00:01.200  1000  1020 I ActivityManager: Starting activity'
+    ' com.example.app/.MainActivity\n'
+    '2026-08-09 22:00:02.300  1000  1030 D WifiService: Enabling Wi-Fi'
+    ' interface wlan0\n'
+    '2026-08-09 22:00:02.500  1000  1030 I DhcpClient: DHCP DISCOVER sent on'
+    ' wlan0\n'
+    '--------- beginning of main\n'
+    '2026-08-09 22:00:02.700  2050  2050 I ExampleApp: App initialized'
+    ' successfully\n'
+    '2026-08-09 22:00:03.100  1000  1030 I DhcpClient: DHCP OFFER received from'
+    ' 192.168.1.1\n'
+    '08-09 22:00:03.400  1000  1040 W BtGatt: Connection retry count 1 for'
+    ' device AA:BB:CC:DD:EE:FF\n'
+    '2026-08-09 22:00:03.600  1000  1030 I DhcpClient: DHCP ACK received,'
+    ' assigned IP 192.168.1.50\n'
+    '08-09 22:00:04.000  1000  1020 I WifiService: Network STATE_CONNECTED on'
+    ' wlan0\n'
+    '08-09 22:00:05.150  2050  2060 E ExampleApp: Failed to connect to'
+    ' server\n'
+    '\tat com.example.app.NetworkClient.connect(NetworkClient.java:42)\n'
+    '\tat com.example.app.MainActivity.onStart(MainActivity.java:108)\n'
+    '08-09 22:00:05.800  1000  1040 F BtGatt: Fatal hardware controller error'
+)
+
+
+class LogcatServiceUserBehaviorTest(unittest.TestCase):
+  """User-facing behavior tests for Logcat service."""
+
+  def setUp(self):
+    self.tmp_dir = tempfile.mkdtemp()
+    self.log_file = os.path.join(self.tmp_dir, 'logcat.txt')
+    with open(self.log_file, 'w', encoding='utf-8') as f:
+      f.write(SAMPLE_REALISTIC_LOGCAT)
+
+    self.mock_serial = '12345'
+    self.ad = mock.MagicMock(name='AndroidDevice', serial=self.mock_serial)
+    self.ad.log = logging.getLogger('mock_ad')
+    self.ad.adb = mock.MagicMock()
+    self.ad.adb.shell.return_value = b'2026-08-09 22:00:00.000'
+
+    self.logcat_service = logcat.Logcat(self.ad)
+    self.logcat_service.adb_logcat_file_path = self.log_file
+
+  def tearDown(self):
+    self.logcat_service.stop()
+    shutil.rmtree(self.tmp_dir)
+
+  def _append_log(self, text: str):
+    with open(self.log_file, 'a', encoding='utf-8', newline='') as f:
+      f.write(text)
+
+  def test_query_logs_by_tag_level_and_pattern(self):
+    # Search for error logs
+    error_logs = self.logcat_service.get_lines(level=['E', 'F'])
+    self.assertEqual(len(error_logs), 2)
+    self.assertEqual([r.tag for r in error_logs], ['ExampleApp', 'BtGatt'])
+    self.assertTrue(error_logs[0].is_error)
+
+    # Filter logs by tag exact match
+    wifi_logs = self.logcat_service.get_lines(tag='WifiService')
+    self.assertEqual(len(wifi_logs), 2)
+    self.assertEqual(
+        [r.message for r in wifi_logs],
+        ['Enabling Wi-Fi interface wlan0', 'Network STATE_CONNECTED on wlan0'],
+    )
+
+    # Search by pattern
+    dhcp_offer = self.logcat_service.get_lines(pattern=r'DHCP OFFER.*192\.168')
+    self.assertEqual(len(dhcp_offer), 1)
+    self.assertEqual(dhcp_offer[0].tag, 'DhcpClient')
+
+  def test_tail_recent_logs(self):
+    recent_logs = self.logcat_service.tail(num_lines=3)
+    self.assertEqual(len(recent_logs), 3)
+    self.assertEqual(recent_logs[-1].tag, 'BtGatt')
+    self.assertEqual(recent_logs[-1].level, 'F')
+
+  def test_now_and_bounded_query(self):
+    # Take position marker before triggering an action
+    start = self.logcat_service.now()
+
+    # Append new logs simulating device activity after start
+    self._append_log(
+        '08-09 22:00:06.000  1000  1030 I WifiService: Disconnected from'
+        ' wlan0\n'
+    )
+
+    lines_since = self.logcat_service.get_lines(
+        pattern='Disconnected', since=start
+    )
+    self.assertEqual(len(lines_since), 1)
+    self.assertEqual(lines_since[0].tag, 'WifiService')
+    self.assertTrue(lines_since[0].position > start)
+
+    # Test passing a LogLine directly to since
+    self._append_log(
+        '08-09 22:00:07.000  1000  1030 I WifiService: Reconnected to wlan0\n'
+    )
+    reconnected_lines = self.logcat_service.get_lines(
+        pattern='Reconnected', since=lines_since[0]
+    )
+    self.assertEqual(len(reconnected_lines), 1)
+    self.assertTrue(reconnected_lines[0] > lines_since[0])
+
+  def test_wait_for_sequential_protocol_handshake(self):
+    handshake_steps = [
+        'DHCP DISCOVER',
+        'DHCP OFFER',
+        'DHCP ACK',
+        'STATE_CONNECTED',
+    ]
+    matched_lines = self.logcat_service.wait_for(
+        handshake_steps, in_order=True, timeout_sec=2.0
+    )
+    self.assertEqual(len(matched_lines), 4)
+    self.assertEqual(
+        [r.tag for r in matched_lines],
+        ['DhcpClient', 'DhcpClient', 'DhcpClient', 'WifiService'],
+    )
+    self.assertTrue(matched_lines[0] < matched_lines[1] < matched_lines[2])
+
+  def test_wait_for_timeout_when_event_does_not_occur(self):
+    with self.assertRaises(logcat.LogcatTimeoutError):
+      self.logcat_service.wait_for(
+          ['Nonexistent System Event'], timeout_sec=0.1
+      )
+
+  def test_listen_realtime_event_stream(self):
+    with self.logcat_service.listen(tag='WifiService') as listener:
+      self.assertFalse(listener.has_events())
+
+      # Simulate background service writing new logcat entry
+      self._append_log(
+          '08-09 22:00:07.000  1000  1030 I WifiService: Reconnected to wlan0\n'
+      )
+
+      event = listener.get_next_event(timeout=2.0)
+      self.assertEqual(event.tag, 'WifiService')
+      self.assertEqual(event.message, 'Reconnected to wlan0')
+      self.assertTrue(listener.has_events())
+
+  def test_features_work_across_and_after_create_output_excerpts(self):
+    # Setup mock filename generator and open logcat file for excerpt tracking
+    self.ad.generate_filename.side_effect = (
+        lambda file_type, test_info=None, extension_name=None: (
+            f'excerpt_{test_info.name}.txt' if test_info else 'logcat.txt'
+        )
+    )
+    self.logcat_service._open_logcat_file()
+
+    # 1. Take a position marker before initial action
+    start_pos = self.logcat_service.now()
+
+    # 2. Append first batch of logs during test_1
+    self._append_log(
+        '08-09 22:00:06.000  1000  1030 I WifiService: Test1 event started\n'
+    )
+    self._append_log(
+        '08-09 22:00:06.500  1000  1030 I WifiService: Test1 event finished\n'
+    )
+
+    # 3. Create excerpt for test_1
+    test_1_output = os.path.join(self.tmp_dir, 'test_1')
+    test_1_record = records.TestResultRecord('test_1')
+    test_1_record.begin_time = 100
+    test_1_record.signature = 'test_1-100'
+    test_1_info = runtime_test_info.RuntimeTestInfo(
+        'test_1', test_1_output, test_1_record
+    )
+    excerpt_1_paths = self.logcat_service.create_output_excerpts(test_1_info)
+    self.assertEqual(len(excerpt_1_paths), 1)
+    with open(excerpt_1_paths[0], 'r', encoding='utf-8') as f:
+      excerpt_1_content = f.read()
+    self.assertIn('Test1 event started', excerpt_1_content)
+    self.assertIn('Test1 event finished', excerpt_1_content)
+
+    # 4. Append second batch of logs during test_2
+    self._append_log(
+        '08-09 22:00:07.000  1000  1030 I WifiService: Test2 event started\n'
+    )
+    self._append_log(
+        '08-09 22:00:07.500  1000  1030 E ExampleApp: Test2 error encountered\n'
+    )
+
+    # 5. Verify query methods work seamlessly across excerpt boundary
+    lines_since_start = self.logcat_service.get_lines(
+        pattern='event', since=start_pos
+    )
+    self.assertEqual(len(lines_since_start), 3)
+    self.assertEqual(
+        [line.message for line in lines_since_start],
+        [
+            'Test1 event started',
+            'Test1 event finished',
+            'Test2 event started',
+        ],
+    )
+
+    # 6. Verify wait_for works after excerpt 1
+    matched = self.logcat_service.wait_for(
+        ['Test2 event started', 'Test2 error encountered'], timeout_sec=2.0
+    )
+    self.assertEqual(len(matched), 2)
+    self.assertEqual(matched[1].level, 'E')
+
+    # 7. Verify tail works after excerpt 1
+    recent = self.logcat_service.tail(num_lines=2)
+    self.assertEqual(len(recent), 2)
+    self.assertEqual(recent[-1].tag, 'ExampleApp')
+
+    # 8. Verify listen works after excerpt 1
+    with self.logcat_service.listen(tag='WifiService') as listener:
+      self._append_log(
+          '08-09 22:00:08.000  1000  1030 I WifiService: Streaming event\n'
+      )
+      event = listener.get_next_event(timeout=2.0)
+      self.assertEqual(event.message, 'Streaming event')
+
+    # 9. Create excerpt for test_2 and assert isolation from test_1
+    test_2_output = os.path.join(self.tmp_dir, 'test_2')
+    test_2_record = records.TestResultRecord('test_2')
+    test_2_record.begin_time = 200
+    test_2_record.signature = 'test_2-200'
+    test_2_info = runtime_test_info.RuntimeTestInfo(
+        'test_2', test_2_output, test_2_record
+    )
+    excerpt_2_paths = self.logcat_service.create_output_excerpts(test_2_info)
+    self.assertEqual(len(excerpt_2_paths), 1)
+    with open(excerpt_2_paths[0], 'r', encoding='utf-8') as f:
+      excerpt_2_content = f.read()
+    self.assertNotIn('Test1', excerpt_2_content)
+    self.assertIn('Test2 event started', excerpt_2_content)
+    self.assertIn('Test2 error encountered', excerpt_2_content)
+    self.assertIn('Streaming event', excerpt_2_content)
+
+
 if __name__ == '__main__':
   unittest.main()
diff --git a/tests/mobly/controllers/android_device_lib/snippet_client_test.py b/tests/mobly/controllers/android_device_lib/snippet_client_test.py
deleted file mode 100755
index 2ed251b..0000000
--- a/tests/mobly/controllers/android_device_lib/snippet_client_test.py
+++ /dev/null
@@ -1,719 +0,0 @@
-# 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.
-
-import unittest
-from unittest import mock
-
-from mobly.controllers.android_device_lib import adb
-from mobly.controllers.android_device_lib import jsonrpc_client_base
-from mobly.controllers.android_device_lib import snippet_client
-from tests.lib import jsonrpc_client_test_base
-from tests.lib import mock_android_device
-
-MOCK_PACKAGE_NAME = 'some.package.name'
-MOCK_MISSING_PACKAGE_NAME = 'not.installed'
-JSONRPC_BASE_CLASS = (
-    'mobly.controllers.android_device_lib.jsonrpc_client_base.JsonRpcClientBase'
-)
-MOCK_USER_ID = 0
-
-
-class SnippetClientTest(jsonrpc_client_test_base.JsonRpcClientTestBase):
-  """Unit tests for mobly.controllers.android_device_lib.snippet_client."""
-
-  def test_check_app_installed_normal(self):
-    sc = self._make_client()
-    sc._check_app_installed()
-
-  def test_check_app_installed_fail_app_not_installed(self):
-    sc = self._make_client(mock_android_device.MockAdbProxy())
-    expected_msg = '.* %s is not installed.' % MOCK_PACKAGE_NAME
-    with self.assertRaisesRegex(
-        snippet_client.AppStartPreCheckError, expected_msg
-    ):
-      sc._check_app_installed()
-
-  def test_check_app_installed_fail_not_instrumented(self):
-    sc = self._make_client(
-        mock_android_device.MockAdbProxy(installed_packages=[MOCK_PACKAGE_NAME])
-    )
-    expected_msg = (
-        '.* %s is installed, but it is not instrumented.' % MOCK_PACKAGE_NAME
-    )
-    with self.assertRaisesRegex(
-        snippet_client.AppStartPreCheckError, expected_msg
-    ):
-      sc._check_app_installed()
-
-  def test_check_app_installed_fail_target_not_installed(self):
-    sc = self._make_client(
-        mock_android_device.MockAdbProxy(
-            instrumented_packages=[
-                (
-                    MOCK_PACKAGE_NAME,
-                    snippet_client._INSTRUMENTATION_RUNNER_PACKAGE,
-                    MOCK_MISSING_PACKAGE_NAME,
-                )
-            ]
-        )
-    )
-    expected_msg = (
-        '.* Instrumentation target %s is not installed.'
-        % MOCK_MISSING_PACKAGE_NAME
-    )
-    with self.assertRaisesRegex(
-        snippet_client.AppStartPreCheckError, expected_msg
-    ):
-      sc._check_app_installed()
-
-  @mock.patch('socket.create_connection')
-  def test_snippet_start(self, mock_create_connection):
-    self.setup_mock_socket_file(mock_create_connection)
-    client = self._make_client()
-    client.connect()
-    result = client.testSnippetCall()
-    self.assertEqual(123, result)
-
-  @mock.patch('socket.create_connection')
-  def test_snippet_start_event_client(self, mock_create_connection):
-    fake_file = self.setup_mock_socket_file(mock_create_connection)
-    client = self._make_client()
-    client.host_port = 123  # normally picked by start_app_and_connect
-    client.connect()
-    fake_file.resp = self.MOCK_RESP_WITH_CALLBACK
-    callback = client.testSnippetCall()
-    self.assertEqual(123, callback.ret_value)
-    self.assertEqual('1-0', callback._id)
-
-    # Check to make sure the event client is using the same port as the
-    # main client.
-    self.assertEqual(123, callback._event_client.host_port)
-
-    fake_file.resp = self.MOCK_RESP_WITH_ERROR
-    with self.assertRaisesRegex(jsonrpc_client_base.ApiError, '1'):
-      callback.getAll('eventName')
-
-  @mock.patch('socket.create_connection')
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'utils.get_available_host_port'
-  )
-  def test_snippet_restore_event_client(
-      self, mock_get_port, mock_create_connection
-  ):
-    mock_get_port.return_value = 789
-    fake_file = self.setup_mock_socket_file(mock_create_connection)
-    client = self._make_client()
-    client.host_port = 123  # normally picked by start_app_and_connect
-    client.device_port = 456
-    client.connect()
-    fake_file.resp = self.MOCK_RESP_WITH_CALLBACK
-    callback = client.testSnippetCall()
-
-    # before reconnect, clients use previously selected ports
-    self.assertEqual(123, client.host_port)
-    self.assertEqual(456, client.device_port)
-    self.assertEqual(123, callback._event_client.host_port)
-    self.assertEqual(456, callback._event_client.device_port)
-
-    # after reconnect, if host port specified, clients use specified port
-    client.restore_app_connection(port=321)
-    self.assertEqual(321, client.host_port)
-    self.assertEqual(456, client.device_port)
-    self.assertEqual(321, callback._event_client.host_port)
-    self.assertEqual(456, callback._event_client.device_port)
-
-    # after reconnect, if host port not specified, clients use selected
-    # available port
-    client.restore_app_connection()
-    self.assertEqual(789, client.host_port)
-    self.assertEqual(456, client.device_port)
-    self.assertEqual(789, callback._event_client.host_port)
-    self.assertEqual(456, callback._event_client.device_port)
-
-    # if unable to reconnect for any reason, a
-    # jsonrpc_client_base.AppRestoreConnectionError is raised.
-    mock_create_connection.side_effect = IOError('socket timed out')
-    with self.assertRaisesRegex(
-        jsonrpc_client_base.AppRestoreConnectionError,
-        (
-            'Failed to restore app connection for %s at host port %s, '
-            'device port %s'
-        )
-        % (MOCK_PACKAGE_NAME, 789, 456),
-    ):
-      client.restore_app_connection()
-
-  @mock.patch('socket.create_connection')
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'utils.start_standing_subprocess'
-  )
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'utils.get_available_host_port'
-  )
-  def test_snippet_start_app_and_connect(
-      self,
-      mock_get_port,
-      mock_start_standing_subprocess,
-      mock_create_connection,
-  ):
-    self.setup_mock_socket_file(mock_create_connection)
-    self._setup_mock_instrumentation_cmd(
-        mock_start_standing_subprocess,
-        resp_lines=[
-            b'SNIPPET START, PROTOCOL 1 0\n',
-            b'SNIPPET SERVING, PORT 123\n',
-        ],
-    )
-    client = self._make_client()
-    client.start_app_and_connect()
-    self.assertEqual(123, client.device_port)
-    self.assertTrue(client.is_alive)
-
-  @mock.patch('socket.create_connection')
-  @mock.patch('mobly.utils.stop_standing_subprocess')
-  def test_snippet_stop_app(
-      self, mock_stop_standing_subprocess, mock_create_connection
-  ):
-    adb_proxy = mock.MagicMock()
-    adb_proxy.shell.return_value = b'OK (0 tests)'
-    client = self._make_client(adb_proxy)
-    client.stop_app()
-    self.assertFalse(client.is_alive)
-
-  def test_snippet_stop_app_raises(self):
-    adb_proxy = mock.MagicMock()
-    adb_proxy.shell.return_value = b'OK (0 tests)'
-    client = self._make_client(adb_proxy)
-    client.host_port = 1
-    client._conn = mock.MagicMock()
-    # Explicitly making the second side_effect noop to avoid uncaught exception
-    # when `__del__` is called after the test is done, which triggers
-    # `disconnect`.
-    client._conn.close.side_effect = [Exception('ha'), None]
-    with self.assertRaisesRegex(Exception, 'ha'):
-      client.stop_app()
-    adb_proxy.forward.assert_called_once_with(['--remove', 'tcp:1'])
-
-  @mock.patch('socket.create_connection')
-  @mock.patch('mobly.utils.stop_standing_subprocess')
-  def test_snippet_stop_app_stops_event_client(
-      self, mock_stop_standing_subprocess, mock_create_connection
-  ):
-    adb_proxy = mock.MagicMock()
-    adb_proxy.shell.return_value = b'OK (0 tests)'
-    client = self._make_client(adb_proxy)
-    event_client = snippet_client.SnippetClient(
-        package=MOCK_PACKAGE_NAME, ad=client._ad
-    )
-    client._event_client = event_client
-    event_client_conn = mock.Mock()
-    event_client._conn = event_client_conn
-
-    client.stop_app()
-    self.assertFalse(client.is_alive)
-    event_client_conn.close.assert_called_once()
-    self.assertIsNone(client._event_client)
-    self.assertIsNone(event_client._conn)
-
-  @mock.patch('socket.create_connection')
-  @mock.patch('mobly.utils.stop_standing_subprocess')
-  def test_snippet_stop_app_stops_event_client_without_connection(
-      self, mock_stop_standing_subprocess, mock_create_connection
-  ):
-    adb_proxy = mock.MagicMock()
-    adb_proxy.shell.return_value = b'OK (0 tests)'
-    client = self._make_client(adb_proxy)
-    event_client = snippet_client.SnippetClient(
-        package=MOCK_PACKAGE_NAME, ad=client._ad
-    )
-    client._event_client = event_client
-    event_client._conn = None
-
-    client.stop_app()
-    self.assertFalse(client.is_alive)
-    self.assertIsNone(client._event_client)
-    self.assertIsNone(event_client._conn)
-
-  @mock.patch('socket.create_connection')
-  @mock.patch('mobly.utils.stop_standing_subprocess')
-  def test_snippet_stop_app_without_event_client(
-      self, mock_stop_standing_subprocess, mock_create_connection
-  ):
-    adb_proxy = mock.MagicMock()
-    adb_proxy.shell.return_value = b'OK (0 tests)'
-    client = self._make_client(adb_proxy)
-    client._event_client = None
-
-    client.stop_app()
-    self.assertFalse(client.is_alive)
-    self.assertIsNone(client._event_client)
-
-  @mock.patch('socket.create_connection')
-  @mock.patch('mobly.utils.stop_standing_subprocess')
-  @mock.patch.object(snippet_client.SnippetClient, 'connect')
-  def test_event_client_does_not_stop_port_forwarding(
-      self, mock_stop_standing_subprocess, mock_create_connection, mock_connect
-  ):
-    adb_proxy = mock.MagicMock()
-    adb_proxy.shell.return_value = b'OK (0 tests)'
-    client = self._make_client(adb_proxy)
-    client.host_port = 12345
-    client.device_port = 67890
-
-    event_client = client._start_event_client()
-    # Mock adb proxy of event client to validate forward call
-    event_client._ad = mock.MagicMock()
-    event_client._adb = event_client._ad.adb
-    client._event_client = event_client
-
-    # Verify that neither the stop process nor the deconstructor is trying to
-    # stop the port forwarding
-    client.stop_app()
-    event_client.__del__()
-
-    event_client._adb.forward.assert_not_called()
-
-  @mock.patch('socket.create_connection')
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'utils.start_standing_subprocess'
-  )
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'utils.get_available_host_port'
-  )
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.SnippetClient.'
-      'disable_hidden_api_blacklist'
-  )
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.SnippetClient.'
-      'stop_app'
-  )
-  def test_start_app_and_connect_precheck_fail(
-      self,
-      mock_stop,
-      mock_precheck,
-      mock_get_port,
-      mock_start_standing_subprocess,
-      mock_create_connection,
-  ):
-    self.setup_mock_socket_file(mock_create_connection)
-    self._setup_mock_instrumentation_cmd(
-        mock_start_standing_subprocess,
-        resp_lines=[
-            b'SNIPPET START, PROTOCOL 1 0\n',
-            b'SNIPPET SERVING, PORT 123\n',
-        ],
-    )
-    client = self._make_client()
-    mock_precheck.side_effect = snippet_client.AppStartPreCheckError(
-        client.ad, 'ha'
-    )
-    with self.assertRaisesRegex(snippet_client.AppStartPreCheckError, 'ha'):
-      client.start_app_and_connect()
-    mock_stop.assert_not_called()
-    self.assertFalse(client.is_alive)
-
-  @mock.patch('socket.create_connection')
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'utils.start_standing_subprocess'
-  )
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'utils.get_available_host_port'
-  )
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.SnippetClient._start_app_and_connect'
-  )
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.SnippetClient.stop_app'
-  )
-  def test_start_app_and_connect_generic_error(
-      self,
-      mock_stop,
-      mock_start,
-      mock_get_port,
-      mock_start_standing_subprocess,
-      mock_create_connection,
-  ):
-    self.setup_mock_socket_file(mock_create_connection)
-    self._setup_mock_instrumentation_cmd(
-        mock_start_standing_subprocess,
-        resp_lines=[
-            b'SNIPPET START, PROTOCOL 1 0\n',
-            b'SNIPPET SERVING, PORT 123\n',
-        ],
-    )
-    client = self._make_client()
-    mock_start.side_effect = Exception('ha')
-    with self.assertRaisesRegex(Exception, 'ha'):
-      client.start_app_and_connect()
-    mock_stop.assert_called_once_with()
-    self.assertFalse(client.is_alive)
-
-  @mock.patch('socket.create_connection')
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'utils.start_standing_subprocess'
-  )
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'utils.get_available_host_port'
-  )
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.SnippetClient._start_app_and_connect'
-  )
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.SnippetClient.stop_app'
-  )
-  def test_start_app_and_connect_fail_stop_also_fail(
-      self,
-      mock_stop,
-      mock_start,
-      mock_get_port,
-      mock_start_standing_subprocess,
-      mock_create_connection,
-  ):
-    self.setup_mock_socket_file(mock_create_connection)
-    self._setup_mock_instrumentation_cmd(
-        mock_start_standing_subprocess,
-        resp_lines=[
-            b'SNIPPET START, PROTOCOL 1 0\n',
-            b'SNIPPET SERVING, PORT 123\n',
-        ],
-    )
-    client = self._make_client()
-    mock_start.side_effect = Exception('Some error')
-    mock_stop.side_effect = Exception('Another error')
-    with self.assertRaisesRegex(Exception, 'Some error'):
-      client.start_app_and_connect()
-    mock_stop.assert_called_once_with()
-    self.assertFalse(client.is_alive)
-
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'SnippetClient._do_start_app'
-  )
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'SnippetClient._check_app_installed'
-  )
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'SnippetClient._read_protocol_line'
-  )
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'SnippetClient.connect'
-  )
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'utils.get_available_host_port'
-  )
-  def test_snippet_start_on_sdk_21(
-      self,
-      mock_get_port,
-      mock_connect,
-      mock_read_protocol_line,
-      mock_check_app_installed,
-      mock_do_start_app,
-  ):
-    """Check that `--user` is not added to start command on SDK < 24."""
-
-    def _mocked_shell(arg):
-      if 'setsid' in arg:
-        raise adb.AdbError('cmd', 'stdout', 'stderr', 'ret_code')
-      else:
-        return b'nohup'
-
-    mock_get_port.return_value = 123
-    mock_read_protocol_line.side_effect = [
-        'SNIPPET START, PROTOCOL 1 234',
-        'SNIPPET SERVING, PORT 1234',
-        'SNIPPET START, PROTOCOL 1 234',
-        'SNIPPET SERVING, PORT 1234',
-        'SNIPPET START, PROTOCOL 1 234',
-        'SNIPPET SERVING, PORT 1234',
-    ]
-
-    # Test 'setsid' exists
-    client = self._make_client()
-    client._ad.build_info['build_version_sdk'] = 21
-    client._adb.shell = mock.Mock(return_value=b'setsid')
-    client.start_app_and_connect()
-    cmd_setsid = '%s am instrument  -w -e action start %s/%s' % (
-        snippet_client._SETSID_COMMAND,
-        MOCK_PACKAGE_NAME,
-        snippet_client._INSTRUMENTATION_RUNNER_PACKAGE,
-    )
-    mock_do_start_app.assert_has_calls([mock.call(cmd_setsid)])
-
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'SnippetClient._do_start_app'
-  )
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'SnippetClient._check_app_installed'
-  )
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'SnippetClient._read_protocol_line'
-  )
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'SnippetClient.connect'
-  )
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'utils.get_available_host_port'
-  )
-  def test_snippet_start_app_and_connect_persistent_session(
-      self,
-      mock_get_port,
-      mock_connect,
-      mock_read_protocol_line,
-      mock_check_app_installed,
-      mock_do_start_app,
-  ):
-    def _mocked_shell(arg):
-      if 'setsid' in arg:
-        raise adb.AdbError('cmd', 'stdout', 'stderr', 'ret_code')
-      else:
-        return b'nohup'
-
-    mock_get_port.return_value = 123
-    mock_read_protocol_line.side_effect = [
-        'SNIPPET START, PROTOCOL 1 234',
-        'SNIPPET SERVING, PORT 1234',
-        'SNIPPET START, PROTOCOL 1 234',
-        'SNIPPET SERVING, PORT 1234',
-        'SNIPPET START, PROTOCOL 1 234',
-        'SNIPPET SERVING, PORT 1234',
-    ]
-
-    # Test 'setsid' exists
-    client = self._make_client()
-    client._adb = mock.MagicMock()
-    client._adb.shell.return_value = b'setsid'
-    client._adb.current_user_id = MOCK_USER_ID
-    client.start_app_and_connect()
-    cmd_setsid = '%s am instrument --user %s -w -e action start %s/%s' % (
-        snippet_client._SETSID_COMMAND,
-        MOCK_USER_ID,
-        MOCK_PACKAGE_NAME,
-        snippet_client._INSTRUMENTATION_RUNNER_PACKAGE,
-    )
-    mock_do_start_app.assert_has_calls([mock.call(cmd_setsid)])
-
-    # Test 'setsid' does not exist, but 'nohup' exsits
-    client = self._make_client()
-    client._adb.shell = _mocked_shell
-    client.start_app_and_connect()
-    cmd_nohup = '%s am instrument --user %s -w -e action start %s/%s' % (
-        snippet_client._NOHUP_COMMAND,
-        MOCK_USER_ID,
-        MOCK_PACKAGE_NAME,
-        snippet_client._INSTRUMENTATION_RUNNER_PACKAGE,
-    )
-    mock_do_start_app.assert_has_calls(
-        [mock.call(cmd_setsid), mock.call(cmd_nohup)]
-    )
-
-    # Test both 'setsid' and 'nohup' do not exist
-    client._adb.shell = mock.Mock(
-        side_effect=adb.AdbError('cmd', 'stdout', 'stderr', 'ret_code')
-    )
-    client = self._make_client()
-    client.start_app_and_connect()
-    cmd_not_persist = ' am instrument --user %s -w -e action start %s/%s' % (
-        MOCK_USER_ID,
-        MOCK_PACKAGE_NAME,
-        snippet_client._INSTRUMENTATION_RUNNER_PACKAGE,
-    )
-    mock_do_start_app.assert_has_calls(
-        [
-            mock.call(cmd_setsid),
-            mock.call(cmd_nohup),
-            mock.call(cmd_not_persist),
-        ]
-    )
-
-  @mock.patch('socket.create_connection')
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'utils.start_standing_subprocess'
-  )
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'utils.get_available_host_port'
-  )
-  def test_snippet_start_app_crash(
-      self,
-      mock_get_port,
-      mock_start_standing_subprocess,
-      mock_create_connection,
-  ):
-    mock_get_port.return_value = 456
-    self.setup_mock_socket_file(mock_create_connection)
-    self._setup_mock_instrumentation_cmd(
-        mock_start_standing_subprocess,
-        resp_lines=[b'INSTRUMENTATION_RESULT: shortMsg=Process crashed.\n'],
-    )
-    client = self._make_client()
-    with self.assertRaisesRegex(
-        snippet_client.ProtocolVersionError,
-        'INSTRUMENTATION_RESULT: shortMsg=Process crashed.',
-    ):
-      client.start_app_and_connect()
-
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'utils.start_standing_subprocess'
-  )
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'utils.get_available_host_port'
-  )
-  def test_snippet_start_app_and_connect_unknown_protocol(
-      self, mock_get_port, mock_start_standing_subprocess
-  ):
-    mock_get_port.return_value = 789
-    self._setup_mock_instrumentation_cmd(
-        mock_start_standing_subprocess,
-        resp_lines=[b'SNIPPET START, PROTOCOL 99 0\n'],
-    )
-    client = self._make_client()
-    with self.assertRaisesRegex(
-        snippet_client.ProtocolVersionError, 'SNIPPET START, PROTOCOL 99 0'
-    ):
-      client.start_app_and_connect()
-
-  @mock.patch('socket.create_connection')
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'utils.start_standing_subprocess'
-  )
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'utils.get_available_host_port'
-  )
-  def test_snippet_start_app_and_connect_header_junk(
-      self,
-      mock_get_port,
-      mock_start_standing_subprocess,
-      mock_create_connection,
-  ):
-    self.setup_mock_socket_file(mock_create_connection)
-    self._setup_mock_instrumentation_cmd(
-        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',
-        ],
-    )
-    client = self._make_client()
-    client.start_app_and_connect()
-    self.assertEqual(123, client.device_port)
-
-  @mock.patch('socket.create_connection')
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'utils.start_standing_subprocess'
-  )
-  @mock.patch(
-      'mobly.controllers.android_device_lib.snippet_client.'
-      'utils.get_available_host_port'
-  )
-  def test_snippet_start_app_and_connect_no_valid_line(
-      self,
-      mock_get_port,
-      mock_start_standing_subprocess,
-      mock_create_connection,
-  ):
-    mock_get_port.return_value = 456
-    self.setup_mock_socket_file(mock_create_connection)
-    self._setup_mock_instrumentation_cmd(
-        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
-        ],
-    )
-    client = self._make_client()
-    with self.assertRaisesRegex(
-        jsonrpc_client_base.AppStartError,
-        'Unexpected EOF waiting for app to start',
-    ):
-      client.start_app_and_connect()
-
-  @mock.patch('builtins.print')
-  def test_help_rpc_when_printing_by_default(self, mock_print):
-    client = self._make_client()
-    mock_rpc = mock.MagicMock()
-    client._rpc = mock_rpc
-
-    result = client.help()
-    mock_rpc.assert_called_once_with('help')
-    self.assertEqual(None, result)
-    mock_print.assert_called_once_with(mock_rpc.return_value)
-
-  @mock.patch('builtins.print')
-  def test_help_rpc_when_not_printing(self, mock_print):
-    client = self._make_client()
-    mock_rpc = mock.MagicMock()
-    client._rpc = mock_rpc
-
-    result = client.help(print_output=False)
-    mock_rpc.assert_called_once_with('help')
-    self.assertEqual(mock_rpc.return_value, result)
-    mock_print.assert_not_called()
-
-  def _make_client(self, adb_proxy=None):
-    adb_proxy = adb_proxy or mock_android_device.MockAdbProxy(
-        instrumented_packages=[
-            (
-                MOCK_PACKAGE_NAME,
-                snippet_client._INSTRUMENTATION_RUNNER_PACKAGE,
-                MOCK_PACKAGE_NAME,
-            )
-        ]
-    )
-    ad = mock.Mock()
-    ad.adb = adb_proxy
-    ad.adb.current_user_id = MOCK_USER_ID
-    ad.build_info = {
-        'build_version_codename': ad.adb.getprop('ro.build.version.codename'),
-        'build_version_sdk': ad.adb.getprop('ro.build.version.sdk'),
-    }
-    return snippet_client.SnippetClient(package=MOCK_PACKAGE_NAME, ad=ad)
-
-  def _setup_mock_instrumentation_cmd(
-      self, mock_start_standing_subprocess, resp_lines
-  ):
-    mock_proc = mock_start_standing_subprocess()
-    mock_proc.stdout.readline.side_effect = resp_lines
-
-
-if __name__ == '__main__':
-  unittest.main()
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
index 7dc8a97..fccdbe5 100644
--- a/tests/mobly/controllers/android_device_lib/snippet_client_v2_test.py
+++ b/tests/mobly/controllers/android_device_lib/snippet_client_v2_test.py
@@ -700,6 +700,35 @@
       'mobly.controllers.android_device_lib.snippet_client_v2.'
       'utils.start_standing_subprocess'
   )
+  def test_start_server_with_am_cmd_prefix(self, mock_start_subprocess):
+    """Checks the starting server command with custom am command prefix."""
+    config = snippet_client_v2.Config(
+        am_cmd_prefix='CLASSPATH=/data/local/tmp/app.apk',
+    )
+    self._make_client(config=config)
+    self._mock_server_process_starting_response(mock_start_subprocess)
+
+    self.client.start_server()
+
+    start_cmd_list = [
+        'adb',
+        'shell',
+        (
+            'CLASSPATH=/data/local/tmp/app.apk'
+            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)],
+    )
+    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()
diff --git a/tests/mobly/expects_test.py b/tests/mobly/expects_test.py
new file mode 100644
index 0000000..ba79fad
--- /dev/null
+++ b/tests/mobly/expects_test.py
@@ -0,0 +1,137 @@
+# 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.
+
+import unittest
+
+from mobly import expects
+from mobly import records
+
+
+class ExpectsTest(unittest.TestCase):
+  """Unit tests for the mobly.expects module."""
+
+  def setUp(self):
+    self.record = records.TestResultRecord('test_foo', 'TestClass')
+    expects.recorder.reset_internal_states(self.record)
+
+  def test_expect_true_pass(self):
+    expects.expect_true(True, 'Should pass')
+    self.assertFalse(expects.recorder.has_error)
+    self.assertEqual(expects.recorder.error_count, 0)
+
+  def test_expect_true_fail(self):
+    expects.expect_true(False, 'Expected true', extras='extra_info')
+    self.assertTrue(expects.recorder.has_error)
+    self.assertEqual(expects.recorder.error_count, 1)
+    err = list(self.record.extra_errors.values())[0]
+    self.assertEqual(err.extras, 'extra_info')
+    self.assertIn('Expected true', err.details)
+
+  def test_expect_false_pass(self):
+    expects.expect_false(False, 'Should pass')
+    self.assertFalse(expects.recorder.has_error)
+    self.assertEqual(expects.recorder.error_count, 0)
+
+  def test_expect_false_fail(self):
+    expects.expect_false(True, 'Expected false', extras='extra_info')
+    self.assertTrue(expects.recorder.has_error)
+    self.assertEqual(expects.recorder.error_count, 1)
+    err = list(self.record.extra_errors.values())[0]
+    self.assertEqual(err.extras, 'extra_info')
+    self.assertIn('Expected false', err.details)
+
+  def test_expect_equal_pass(self):
+    expects.expect_equal(1, 1, 'Should pass')
+    self.assertFalse(expects.recorder.has_error)
+    self.assertEqual(expects.recorder.error_count, 0)
+
+  def test_expect_equal_fail(self):
+    expects.expect_equal(1, 2, 'Values not equal', extras='extra_info')
+    self.assertTrue(expects.recorder.has_error)
+    self.assertEqual(expects.recorder.error_count, 1)
+    err = list(self.record.extra_errors.values())[0]
+    self.assertEqual(err.extras, 'extra_info')
+    self.assertIn('Values not equal', err.details)
+
+  def test_expect_no_raises_context_manager_pass(self):
+    with expects.expect_no_raises():
+      _ = 1 + 1
+    self.assertFalse(expects.recorder.has_error)
+    self.assertEqual(expects.recorder.error_count, 0)
+
+  def test_expect_no_raises_context_manager_fail(self):
+    with expects.expect_no_raises(message='Context error', extras='extra_info'):
+      raise ValueError('something went wrong')
+    self.assertTrue(expects.recorder.has_error)
+    self.assertEqual(expects.recorder.error_count, 1)
+    err = list(self.record.extra_errors.values())[0]
+    self.assertEqual(err.extras, 'extra_info')
+    self.assertIn('Context error', err.details)
+    self.assertIn('something went wrong', err.details)
+
+  def test_expect_no_raises_bare_decorator_on_arbitrary_function(self):
+    """Verifies @expects.expect_no_raises on any arbitrary function."""
+
+    @expects.expect_no_raises
+    def arbitrary_helper(a, b, fail=False):
+      if fail:
+        raise RuntimeError('Helper failed')
+      return a + b
+
+    # Success case on arbitrary function
+    result = arbitrary_helper(3, 4, fail=False)
+    self.assertEqual(result, 7)
+    self.assertFalse(expects.recorder.has_error)
+
+    # Failure case on arbitrary function
+    result = arbitrary_helper(3, 4, fail=True)
+    self.assertIsNone(result)
+    self.assertTrue(expects.recorder.has_error)
+    self.assertEqual(expects.recorder.error_count, 1)
+
+  def test_expect_no_raises_parameterized_decorator_on_arbitrary_function(self):
+    """Verifies @expects.expect_no_raises(...) on any arbitrary function."""
+
+    @expects.expect_no_raises(message='Custom step error', extras={'step': 1})
+    def custom_calculation(x, y):
+      if y == 0:
+        raise ZeroDivisionError('divide by zero')
+      return x / y
+
+    # Success case
+    self.assertEqual(custom_calculation(10, 2), 5)
+    self.assertFalse(expects.recorder.has_error)
+
+    # Failure case
+    self.assertIsNone(custom_calculation(10, 0))
+    self.assertTrue(expects.recorder.has_error)
+    self.assertEqual(expects.recorder.error_count, 1)
+    err = list(self.record.extra_errors.values())[0]
+    self.assertEqual(err.extras, {'step': 1})
+    self.assertIn('Custom step error', err.details)
+
+  def test_expect_no_raises_decorator_preserves_function_metadata(self):
+    """Verifies that docstrings, __name__, and metadata are preserved."""
+
+    @expects.expect_no_raises
+    def sample_func(x):
+      """Sample documentation."""
+      return x
+
+    self.assertEqual(sample_func.__name__, 'sample_func')
+    self.assertEqual(sample_func.__doc__, 'Sample documentation.')
+
+
+if __name__ == '__main__':
+  unittest.main()
diff --git a/tests/mobly/logger_test.py b/tests/mobly/logger_test.py
index d975ac7..f27ce3e 100755
--- a/tests/mobly/logger_test.py
+++ b/tests/mobly/logger_test.py
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import datetime
 import logging
 import os
 import shutil
@@ -20,7 +21,6 @@
 from unittest import mock
 
 from mobly import logger
-import pytz
 
 
 class LoggerTest(unittest.TestCase):
@@ -34,10 +34,23 @@
 
   def test_epoch_to_log_line_timestamp(self):
     actual_stamp = logger.epoch_to_log_line_timestamp(
-        1469134262116, time_zone=pytz.utc
+        1469134262116, time_zone=datetime.timezone.utc
     )
     self.assertEqual('07-21 20:51:02.116', actual_stamp)
 
+  def test_epoch_to_log_line_timestamp_pads_milliseconds(self):
+    for epoch_time, expected_stamp in [
+        (1469134262000, '07-21 20:51:02.000'),
+        (1469134262005, '07-21 20:51:02.005'),
+        (1469134262050, '07-21 20:51:02.050'),
+        (1469134262099, '07-21 20:51:02.099'),
+    ]:
+      actual_stamp = logger.epoch_to_log_line_timestamp(
+          epoch_time, time_zone=datetime.timezone.utc
+      )
+      self.assertEqual(expected_stamp, actual_stamp)
+      self.assertTrue(logger.is_valid_logline_timestamp(actual_stamp))
+
   def test_is_valid_logline_timestamp(self):
     self.assertTrue(logger.is_valid_logline_timestamp('06-21 17:44:42.336'))
 
diff --git a/tests/mobly/suite_runner_test.py b/tests/mobly/suite_runner_test.py
index a81c098..4ccdd6c 100755
--- a/tests/mobly/suite_runner_test.py
+++ b/tests/mobly/suite_runner_test.py
@@ -22,12 +22,14 @@
 import unittest
 from unittest import mock
 
+from mobly import asserts
 from mobly import base_suite
 from mobly import base_test
 from mobly import records
 from mobly import suite_runner
 from mobly import test_runner
 from mobly import utils
+from tests.lib import dynamic_repeat_retry_test
 from tests.lib import integration2_test
 from tests.lib import integration_test
 from tests.lib import integration_test_suite
@@ -128,6 +130,28 @@
     mock_exit.assert_not_called()
 
   @mock.patch('sys.exit')
+  def test_run_suite_with_dynamic_repeat_and_retry(self, mock_exit):
+    tmp_file_path = os.path.join(self.tmp_dir, 'config.yml')
+    with io.open(tmp_file_path, 'w', encoding='utf-8') as f:
+      f.write(
+          """
+        TestBeds:
+          - Name: SampleTestBed
+            Controllers:
+              MagicDevice: '*'
+            TestParams:
+              repeat_key: 4
+              retry_key: 3
+      """
+      )
+    suite_runner.run_suite(
+        [dynamic_repeat_retry_test.DynamicTest], argv=['-c', tmp_file_path]
+    )
+    mock_exit.assert_not_called()
+    self.assertEqual(dynamic_repeat_retry_test.DynamicTest.repeat_counter, 4)
+    self.assertEqual(dynamic_repeat_retry_test.DynamicTest.retry_counter, 3)
+
+  @mock.patch('sys.exit')
   def test_run_suite_with_failures(self, mock_exit):
     tmp_file_path = os.path.join(self.tmp_dir, 'config.yml')
     with io.open(tmp_file_path, 'w', encoding='utf-8') as f:
@@ -465,6 +489,102 @@
       )
     return tmp_file_path
 
+  @mock.patch('sys.exit')
+  def test_run_suite_with_abort_all_clean_run(self, mock_sys_exit):
+    class CleanAbortSuiteTest(base_test.BaseTestClass):
+
+      def setup_class(self):
+        asserts.abort_all('Intentional clean abort in suite.')
+
+      def test_1(self):
+        pass
+
+    tmp_file_path = self._gen_tmp_config_file()
+    suite_runner.run_suite(
+        [CleanAbortSuiteTest],
+        argv=['-c', tmp_file_path, '-tb', 'SampleTestBed'],
+    )
+    mock_sys_exit.assert_not_called()
+
+  @mock.patch('sys.exit')
+  def test_run_suite_with_abort_all_failing_run(self, mock_sys_exit):
+    class FailingAbortSuiteTest(base_test.BaseTestClass):
+
+      def setup_class(self):
+        asserts.fail('Setup failed.')
+
+      def on_fail(self, record):
+        asserts.abort_all('Abort after failure.')
+
+      def test_1(self):
+        pass
+
+    tmp_file_path = self._gen_tmp_config_file()
+    suite_runner.run_suite(
+        [FailingAbortSuiteTest],
+        argv=['-c', tmp_file_path, '-tb', 'SampleTestBed'],
+    )
+    mock_sys_exit.assert_called_once_with(1)
+
+  @mock.patch('sys.exit')
+  def test_run_suite_class_with_abort_all_clean_run(self, mock_sys_exit):
+    class CleanAbortTest(base_test.BaseTestClass):
+
+      def setup_class(self):
+        asserts.abort_all('Intentional clean abort in suite class.')
+
+      def test_1(self):
+        pass
+
+    class CleanTestSuite(base_suite.BaseSuite):
+
+      def setup_suite(self, config):
+        self.add_test_class(CleanAbortTest)
+
+    tmp_file_path = self._gen_tmp_config_file()
+    mock_cli_args = ['test_binary', f'--config={tmp_file_path}']
+    sys.modules['__main__'].__dict__[CleanTestSuite.__name__] = CleanTestSuite
+
+    with mock.patch.object(sys, 'argv', new=mock_cli_args):
+      try:
+        suite_runner.run_suite_class()
+      finally:
+        del sys.modules['__main__'].__dict__[CleanTestSuite.__name__]
+
+    mock_sys_exit.assert_not_called()
+
+  @mock.patch('sys.exit')
+  def test_run_suite_class_with_abort_all_failing_run(self, mock_sys_exit):
+    class FailingAbortTest(base_test.BaseTestClass):
+
+      def setup_class(self):
+        asserts.fail('Setup failed in suite class.')
+
+      def on_fail(self, record):
+        asserts.abort_all('Abort after failure.')
+
+      def test_1(self):
+        pass
+
+    class FailingTestSuite(base_suite.BaseSuite):
+
+      def setup_suite(self, config):
+        self.add_test_class(FailingAbortTest)
+
+    tmp_file_path = self._gen_tmp_config_file()
+    mock_cli_args = ['test_binary', f'--config={tmp_file_path}']
+    sys.modules['__main__'].__dict__[
+        FailingTestSuite.__name__
+    ] = FailingTestSuite
+
+    with mock.patch.object(sys, 'argv', new=mock_cli_args):
+      try:
+        suite_runner.run_suite_class()
+      finally:
+        del sys.modules['__main__'].__dict__[FailingTestSuite.__name__]
+
+    mock_sys_exit.assert_called_once_with(1)
+
 
 if __name__ == '__main__':
   unittest.main()
diff --git a/tests/mobly/test_runner_test.py b/tests/mobly/test_runner_test.py
index 0bdf512..b153145 100755
--- a/tests/mobly/test_runner_test.py
+++ b/tests/mobly/test_runner_test.py
@@ -21,6 +21,8 @@
 import unittest
 from unittest import mock
 
+from mobly import asserts
+from mobly import base_test
 from mobly import config_parser
 from mobly import records
 from mobly import signals
@@ -457,6 +459,36 @@
     mock_cls_instance._pre_run.side_effect = Exception('Something went wrong.')
     mock_cls_instance._clean_up.assert_called_once()
 
+  @mock.patch('sys.exit')
+  @mock.patch.object(test_runner, '_find_test_class')
+  @mock.patch.object(config_parser, 'load_test_config_file')
+  def test_main_with_abort_all_failing_run(
+      self, mock_load_config, mock_find_class, mock_sys_exit
+  ):
+    mock_load_config.return_value = [self.base_mock_test_config]
+    mock_find_class.return_value = integration3_test.Integration3Test
+    test_runner.main(argv=['-c', 'dummy_config.yaml'])
+    mock_sys_exit.assert_called_once_with(1)
+
+  @mock.patch('sys.exit')
+  @mock.patch.object(test_runner, '_find_test_class')
+  @mock.patch.object(config_parser, 'load_test_config_file')
+  def test_main_with_abort_all_clean_run(
+      self, mock_load_config, mock_find_class, mock_sys_exit
+  ):
+    class CleanAbortTest(base_test.BaseTestClass):
+
+      def setup_class(self):
+        asserts.abort_all('Intentional clean abort.')
+
+      def test_1(self):
+        pass
+
+    mock_load_config.return_value = [self.base_mock_test_config]
+    mock_find_class.return_value = CleanAbortTest
+    test_runner.main(argv=['-c', 'dummy_config.yaml'])
+    mock_sys_exit.assert_not_called()
+
 
 if __name__ == '__main__':
   unittest.main()
diff --git a/tests/mobly/utils_test.py b/tests/mobly/utils_test.py
index 84f5afd..2c29dfc 100755
--- a/tests/mobly/utils_test.py
+++ b/tests/mobly/utils_test.py
@@ -22,7 +22,6 @@
 import signal
 import socket
 import subprocess
-import sys
 import tempfile
 import threading
 import time
@@ -364,6 +363,16 @@
 
     self.assertIsInstance(out, str)
 
+  def test_run_command_with_text_false(self):
+    _, out, _ = utils.run_command(self.sleep_cmd(0.01), text=False)
+
+    self.assertIsInstance(out, bytes)
+
+  def test_run_command_with_text_true(self):
+    _, out, _ = utils.run_command(self.sleep_cmd(0.01), text=True)
+
+    self.assertIsInstance(out, str)
+
   def test_start_standing_subproc(self):
     try:
       p = utils.start_standing_subprocess(self.sleep_cmd(4))
@@ -487,10 +496,6 @@
     subprocess_a.join(timeout=1)
     mock_subprocess_a_popen.wait.assert_called_once()
 
-  @unittest.skipIf(
-      sys.version_info >= (3, 4) and sys.version_info < (3, 5),
-      'Python 3.4 does not support `None` max_workers.',
-  )
   def test_concurrent_exec_when_none_workers(self):
     def adder(a, b):
       return a + b
diff --git a/tox.ini b/tox.ini
index 402351a..4be967a 100644
--- a/tox.ini
+++ b/tox.ini
@@ -4,7 +4,6 @@
 [testenv]
 deps =
     pytest
-    pytz
 commands =
     pytest