feat(scripts): device-testing perf harness

Add a cross-platform, standard-library-only Python package under
scripts/device-testing/ that replicates LibreMail's on-device performance-test
scenarios and logging capture, codifying the methodology run by hand on
2026-07-05 (Pixel 10 Pro XL).

Modules:
- breadcrumbs.py: a pure, unit-tested parser for the ImapPerf / MailReader /
  Reader / MailBackfiller breadcrumbs, plus open-correlation that reproduces the
  manual timing-tables.md figures exactly.
- adb.py: a safety-guarded adb wrapper -- an allow-list of adb subcommands and a
  deny-list + assertions on shell commands. The only sanctioned app-state
  mutation is clearing LibreMail's own cache/ (exact-match); no pm clear /
  uninstall / data wipe, and no touching databases/ files/ shared_prefs/
  datastore/ can be constructed.
- uidump.py: uiautomator XML parser + screen recognition (mailbox rows with the
  cached "Available offline" flag, reader, and the keyguard / foreign-app guards).
- scenarios.py: cold-open, message-open (uncached), back-nav, prefetch A/B
  (fetch-policy toggle) and cross-provider, each keyguard-guarded and driven
  through the guarded wrapper.
- report.py + perf_harness.py: aggregates, a timing-tables.md renderer mirroring
  the manual write-up, and the CLI (timestamped run dir with the raw logcat, a
  filtered breadcrumb extract, and the tables). --dry-run prints the exact
  command plan without touching device state.

Tests (stdlib unittest, 68 cases) validate the parser, guardrails, UI
recognition and report against the manual run's real captures (fixtures include
a verbatim perf-extract slice and the reader/lockscreen/alarm dumps). Track the
*.log fixture past the gitignore *.log rule via a scoped negation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 13:21:34 -05:00
co-authored by Claude Opus 4.8
parent b8d67557a0
commit e436503eaf
18 changed files with 2761 additions and 0 deletions
+2
View File
@@ -30,6 +30,8 @@ secrets.properties
# Log Files
*.log
# ...but keep the device-testing parser fixtures (verbatim logcat slices used as test inputs)
!scripts/device-testing/tests/fixtures/*.log
# Android Studio / IntelliJ
.idea/
+159
View File
@@ -0,0 +1,159 @@
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
# LibreMail device-testing perf harness
A cross-platform, **standard-library-only** Python tool that replicates LibreMail's
on-device performance-test scenarios and logging capture. It codifies the methodology that
was first run by hand (2026-07-05, Pixel 10 Pro XL) and written up in
`scratchpad/perf/perf_summary.md`, `causation-report.md`, and `ab-run/timing-tables.md`.
Everything runs against **LibreMail only** plus read-only system-log/settings/dumpsys
collection, behind hard device-safety guardrails (see [Device safety](#device-safety)).
## Requirements
- **Python 3.8+** (standard library only — no `pip install`, no third-party deps).
- **`adb`** on `PATH` (or pass `--adb /path/to/adb`).
- A connected device with:
- **LibreMail installed** as a **debuggable** build (the cache-clear uses `run-as`, which
only works on debuggable APKs), and
- **at least one account signed in** with some **not-yet-cached** messages in the inbox
(message bodies are fetched on first open), and
- the screen **unlocked** (the harness keeps it awake during a run and guards every step
against the keyguard, but it cannot get you *past* a secure lock screen).
No build step. Run it straight from the repo.
## Usage
```bash
python scripts/device-testing/perf_harness.py <scenario> [options]
```
Scenarios (each independently selectable):
| Scenario | What it does |
|------------------|--------------|
| `cold-open` | Force-stop LibreMail, clear **only** its `cache/`, `am start -W` ×N, parse `TotalTime`/`WaitTime`. |
| `message-open` | Open N distinct **uncached** messages one at a time; time spinner→content from the breadcrumbs. |
| `back-nav` | Time reader→mailbox back transitions ×N (dump-latency-bound; see caveat in the report). |
| `prefetch-ab` | Run `message-open` under **Fetch all on Wi-Fi** (prefetch ON) vs **Always on-demand** (prefetch OFF), cache cleared between conditions. |
| `cross-provider` | Open N messages from the (unified) inbox and tabulate per provider (`imap:…` vs `outlook:…`) from the breadcrumb account refs. |
Common options:
| Option | Default | Meaning |
|--------|---------|---------|
| `--serial <id>` | auto (if exactly one device) | choose the device |
| `-n, --count <N>` | per-scenario | samples / runs (per condition for `prefetch-ab`) |
| `--out <dir>` | `./device-perf-runs` | output root; a timestamped subdir is created per run |
| `--package <pkg>` | `org.libremail.app` | target package |
| `--component <c>` | `org.libremail.app/org.libremail.MainActivity` | launcher component |
| `--adb <path>` | `adb` | path to the adb executable |
| `--dry-run` | off | **print the exact command plan without changing device state** |
**Always start with `--dry-run`** to review the command plan a scenario will issue:
```bash
python scripts/device-testing/perf_harness.py prefetch-ab --dry-run
python scripts/device-testing/perf_harness.py cold-open -n 5 --serial 5C310DLCQ000G3
```
## What each scenario measures
Timing comes **primarily from the on-device breadcrumbs** (PII-free), with uiautomator used
only as a "content is ready" signal so the driver knows when to move on:
- `MailReader: openMessage <acctRef> folder=<label> fetchedBody=<bool> took=<ms>ms`
— end-to-end reader open. `fetchedBody=true` ⇒ a real network body fetch (uncached).
- `Reader: reader ready took=<ms>ms html=<bool> inline=<n>` — spinner→content.
- `ImapPerf: <op> connect=<ms>ms work=<ms>ms live=<N>` and
`ImapPerf: body-fetch select=<ms>ms body=<ms>ms flag=<ms>ms rfc822=<n>B chars=<n> att=<n>`
— connection + phase split; `body KB/s` is `rfc822 / body_ms`.
- `MailBackfiller: backfill … pages=<n> complete=<bool>` / `backfill slice…` — backfill activity.
`cold-open` instead parses `am start -W`'s `TotalTime` / `WaitTime`.
## Output
Each run writes a timestamped directory under `--out`:
```
device-perf-runs/20260706-131612-cold-open/
├── timing-tables.md # per-scenario tables + aggregates (mirrors the manual timing-tables.md)
├── session-raw.log # the full `adb logcat -b all -v threadtime` stream for the run
├── perf-extract.log # the ImapPerf|MailReader|Reader|MailBackfiller subset of the raw log
└── driver.log # what the harness did, step by step
```
## Device safety
Every device call goes through a guarded `adb` wrapper (`adb.py`). Two independent layers
mean a dangerous command **cannot be constructed**:
- **Allow-list** of adb subcommands: `devices`, `get-state`, `install`, `shell`, `logcat`,
`wait-for-device`, `start-server`. Anything else (`uninstall`, `root`, `remount`,
`reboot`, `disable-verity`, `emu`, `push`, `pull`, …) is refused.
- **Deny-list + assertions** on every `shell` command: no `pm clear` / `pm uninstall`, no
reboot/remount/root/verity/factory-reset, **no touching the app's `databases/` / `files/`
/ `shared_prefs/` / `datastore/`**, no output redirects, and `run-as` / `am force-stop` /
`am start` are constrained to the target package.
The **only** sanctioned mutation of app state is clearing LibreMail's own **`cache/`**
(`run-as org.libremail.app sh -c 'rm -rf cache/*'`) — an exact-match allow-list; any other
`rm`/`mv`/`dd`/… is refused. There is **no** `pm clear`, uninstall, or data wipe anywhere.
The screen is kept awake (`svc power stayon true` + `KEYCODE_WAKEUP`) for the run and
restored afterwards, and every uiautomator/input step is guarded against the keyguard and
against a foreign app being in the foreground — a sample taken against either is **skipped**,
not measured (the manual run hit exactly these: a lock-screen dump and a deskclock alarm).
## Tests
Pure-logic modules (the breadcrumb parser, the uiautomator parser, the safety guardrails,
the report renderers) are unit-tested with the standard-library `unittest` against the
**real captures** from the manual run:
```bash
python -m unittest discover -s scripts/device-testing/tests -p "test_*.py"
```
`tests/fixtures/perf-extract-sample.log` is a verbatim slice of the manual run's
`perf-extract-ALL.log`, so the parser tests assert the harness reproduces the exact figures
in the hand-written `timing-tables.md` (e.g. Gmail A1: `took=31227 ms`, `rfc822=60457 B`,
`4.2 KB/s`; Outlook O1: `took=2934 ms`, `139.5 KB/s`). The UI fixtures include the reader,
plus the lockscreen and deskclock-alarm negatives the keyguard/foreground guards must catch.
## Validated vs. needs the live run
**Validated offline** (by the unit tests, no device):
- Breadcrumb parsing + open-correlation reproduce the manual `timing-tables.md` figures.
- The safety guardrails accept the known-good commands and refuse every forbidden one.
- Screen recognition (mailbox rows + cached flag, reader, lockscreen, foreign app).
- The report renders the same tables/aggregates as the manual write-up.
- `--dry-run` emits the correct command plan for all five scenarios.
**Needs the first monitored live run** (a device makes the state real):
- End-to-end timing capture on hardware (streamed logcat → per-sample breadcrumb tailing).
- **Settings-screen navigation for `prefetch-ab`.** No uiautomator dump of the settings
screen was captured in the manual run, so `set_fetch_policy` navigates by the on-screen
option text (`"Fetch all on Wi-Fi"`, `"Always on-demand"`, from `res/values/strings.xml`)
with a scroll fallback. The bottom-nav "Settings" tap target is confirmed from
`ui_mailbox.xml`; the option rows themselves need one live confirmation.
- Row selection under a live, scrolling list and the auto-lock recovery path.
## Notes / design decisions
- **Uncached opens.** Message bodies live in the Room DB (`libremail.db`), **not** in
`cache/`, so bodies can't be force-uncached without touching `databases/` (forbidden).
The harness therefore opens **naturally-uncached** messages and verifies each was a real
network fetch via the `fetchedBody=true` breadcrumb — exactly as the manual run did.
- **Cross-provider** uses the unified inbox: a single mixed pass yields both providers, and
the harness buckets rows by the breadcrumb account ref (`imap:…` vs `outlook:…`) — no
account switching required.
- **Back-nav** timings are dominated by the ~2.5–3 s uiautomator-dump latency floor; the
report labels them accordingly (true in-app back is sub-second and not resolvable via adb
UI polling under load).
- Dev-script convention: Python 3, standard library only, cross-platform (Windows-primary),
matching `.claude/skills/preflight/*.py`.
+370
View File
@@ -0,0 +1,370 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""adb.py -- a deliberately narrow, safety-guarded wrapper around ``adb``.
Every device interaction in the harness goes through :class:`Adb`. The wrapper enforces
two independent layers so a dangerous command *cannot be constructed*, even by a buggy
caller:
* a TOP-LEVEL ALLOW-LIST of adb subcommands (``devices``, ``get-state``, ``install``,
``shell``, ``logcat``, ``wait-for-device``) -- anything else raises :class:`AdbSafetyError`;
* a DENY-LIST + assertions applied to every ``shell`` command: no ``pm clear`` / ``pm
uninstall``, no ``reboot`` / ``remount`` / ``root`` / ``disable-verity`` / factory reset,
no touching the app's ``databases/`` / ``files/`` / ``shared_prefs/`` / ``datastore/``,
no output redirects, and the ONLY sanctioned destructive operation is clearing the app's
own ``cache/`` (an exact-match allow-list of ``run-as <pkg> sh -c 'rm -rf cache/*'``).
``am force-stop`` / ``am start`` and ``run-as`` are constrained to the target package
(default ``org.libremail.app``). All checks run in ``--dry-run`` mode too, so the guardrails
are unit-testable without a device.
Scope: this harness operates ONLY on LibreMail plus read-only system-log/settings/dumpsys
collection. It never writes app data other than clearing the cache.
"""
from __future__ import annotations
import re
import subprocess
import time
from dataclasses import dataclass
from typing import List, Optional, Sequence
DEFAULT_PACKAGE = "org.libremail.app"
DEFAULT_COMPONENT = "org.libremail.app/org.libremail.MainActivity"
# adb subcommands the harness is ever allowed to invoke.
_ALLOWED_SUBCOMMANDS = frozenset(
{"devices", "get-state", "install", "shell", "logcat", "wait-for-device", "start-server"}
)
# Substrings that must never appear anywhere in a shell command line.
_DENY_SUBSTRINGS = (
"pm clear",
"pm uninstall",
"pm disable",
"pm hide",
"disable-verity",
"set-verity",
"remount",
"reboot",
"bootloader",
"fastboot",
"factory",
"wipe-data",
"wipe_data",
"mkfs",
"format ",
# App-private dirs we must never read or write (cache/ is the sole exception, handled
# separately). Banning the segment names outright is defence-in-depth for this harness,
# which never has a legitimate reason to touch them.
"databases",
"shared_prefs",
"datastore",
"/files",
"files/",
)
# Verbs that mutate the filesystem. Detected as whole *words* anywhere in the shell command
# (they can be buried inside a ``sh -c '<payload>'`` token); permitted ONLY for the exact
# sanctioned cache-clear (see _is_sanctioned_cache_clear).
_DESTRUCTIVE_VERBS = ("rm", "rmdir", "mv", "dd", "truncate", "shred", "unlink", "mkfs", "chmod", "chown")
_DESTRUCTIVE_RE = re.compile(r"\b(?:" + "|".join(_DESTRUCTIVE_VERBS) + r")\b")
# The exact ``sh -c`` payloads permitted for the cache clear -- nothing else.
_CACHE_CLEAR_PAYLOADS = ("rm -rf cache/*", "rm -rf cache")
class AdbSafetyError(RuntimeError):
"""Raised when a command would violate the harness's device-safety guardrails."""
class AdbError(RuntimeError):
"""Raised when an adb command fails (non-zero exit) and the caller wanted a check."""
@dataclass
class AdbResult:
"""Outcome of one adb invocation."""
args: List[str]
returncode: int
stdout: str
stderr: str
@property
def ok(self) -> bool:
return self.returncode == 0
def _shell_command_string(args: Sequence[str]) -> str:
"""Join the tokens *after* ``shell`` into a single inspection string."""
return " ".join(args[1:]) if len(args) > 1 else ""
def _is_sanctioned_cache_clear(package: str, args: Sequence[str]) -> bool:
"""True iff ``args`` is exactly ``shell run-as <package> sh -c '<cache-clear>'``."""
return (
len(args) == 6
and args[0] == "shell"
and args[1] == "run-as"
and args[2] == package
and args[3] == "sh"
and args[4] == "-c"
and args[5] in _CACHE_CLEAR_PAYLOADS
)
def assert_safe(package: str, args: Sequence[str]) -> None:
"""Raise :class:`AdbSafetyError` if ``args`` (an adb argv, sans ``adb``/``-s``) is unsafe.
Pure and side-effect-free so the guardrails can be unit-tested exhaustively.
"""
if not args:
raise AdbSafetyError("empty adb command")
subcommand = args[0]
if subcommand not in _ALLOWED_SUBCOMMANDS:
raise AdbSafetyError(f"adb subcommand {subcommand!r} is not on the allow-list")
# 'install' may only take known-safe flags plus a path; it is never 'uninstall'.
if subcommand == "install":
for tok in args[1:]:
if tok.startswith("-") and tok not in ("-r", "-t", "-g", "-d", "-i"):
raise AdbSafetyError(f"install flag {tok!r} is not allowed")
return
if subcommand != "shell":
# devices / get-state / logcat / wait-for-device / start-server take no risky args.
return
sanctioned_cache_clear = _is_sanctioned_cache_clear(package, args)
sh = _shell_command_string(args)
lowered = sh.lower()
for bad in _DENY_SUBSTRINGS:
if bad in lowered:
raise AdbSafetyError(f"shell command contains forbidden text {bad!r}: {sh!r}")
if ">" in sh:
raise AdbSafetyError(f"shell output redirection is not allowed: {sh!r}")
tokens = list(args[1:])
if _DESTRUCTIVE_RE.search(sh) and not sanctioned_cache_clear:
raise AdbSafetyError(
"the only permitted destructive shell command is clearing the app cache; "
f"refusing: {sh!r}"
)
# run-as must target our package and (unless the sanctioned cache clear) be a read.
if "run-as" in tokens:
idx = tokens.index("run-as")
target = tokens[idx + 1] if idx + 1 < len(tokens) else None
if target != package:
raise AdbSafetyError(f"run-as may only target {package!r}, got {target!r}: {sh!r}")
# am force-stop / am start may only reference our package/component.
if "am" in tokens:
if "force-stop" in tokens:
i = tokens.index("force-stop")
target = tokens[i + 1] if i + 1 < len(tokens) else None
if target != package:
raise AdbSafetyError(f"am force-stop must target {package!r}: {sh!r}")
if "-n" in tokens: # component: <package>/<activity>
i = tokens.index("-n")
component = tokens[i + 1] if i + 1 < len(tokens) else ""
comp_pkg = component.split("/", 1)[0]
if comp_pkg != package:
raise AdbSafetyError(f"am start component must be in {package!r}: {sh!r}")
for pkg_flag in ("-p", "--package"):
if pkg_flag in tokens:
i = tokens.index(pkg_flag)
target = tokens[i + 1] if i + 1 < len(tokens) else None
if target != package:
raise AdbSafetyError(f"am {pkg_flag} must be {package!r}: {sh!r}")
class Adb:
"""Guarded adb wrapper bound to a single device serial and target package."""
def __init__(
self,
serial: Optional[str] = None,
package: str = DEFAULT_PACKAGE,
adb_path: str = "adb",
dry_run: bool = False,
default_timeout: float = 60.0,
logger=None,
) -> None:
self.serial = serial
self.package = package
self.adb_path = adb_path
self.dry_run = dry_run
self.default_timeout = default_timeout
self._log = logger or (lambda msg: None)
# -- core ---------------------------------------------------------------- #
def _argv(self, args: Sequence[str]) -> List[str]:
prefix = [self.adb_path]
if self.serial:
prefix += ["-s", self.serial]
return prefix + list(args)
def run(
self,
args: Sequence[str],
check: bool = False,
timeout: Optional[float] = None,
) -> AdbResult:
"""Validate and execute an adb command (``args`` excludes ``adb`` and ``-s <serial>``)."""
assert_safe(self.package, args)
argv = self._argv(args)
printable = " ".join(argv)
if self.dry_run:
self._log(f"[dry-run] {printable}")
return AdbResult(list(args), 0, "", "")
self._log(f"$ {printable}")
completed = subprocess.run(
argv,
capture_output=True,
text=True,
timeout=timeout if timeout is not None else self.default_timeout,
)
result = AdbResult(list(args), completed.returncode, completed.stdout, completed.stderr)
if check and not result.ok:
raise AdbError(f"adb failed ({result.returncode}): {printable}\n{result.stderr}")
return result
# -- device discovery ---------------------------------------------------- #
def devices(self) -> List[str]:
"""Return the serials of attached, ready devices."""
out = self.run(["devices"]).stdout
return parse_devices(out)
def get_state(self) -> str:
return self.run(["get-state"]).stdout.strip()
# -- lifecycle (LibreMail only) ------------------------------------------ #
def force_stop(self) -> AdbResult:
return self.run(["shell", "am", "force-stop", self.package])
def start_activity(
self, component: str = DEFAULT_COMPONENT, wait: bool = True
) -> AdbResult:
args = ["shell", "am", "start"]
if wait:
args.append("-W")
args += ["-n", component]
return self.run(args)
def clear_cache(self) -> AdbResult:
"""Clear ONLY the app's ``cache/`` via run-as. The sole sanctioned mutation."""
return self.run(["shell", "run-as", self.package, "sh", "-c", "rm -rf cache/*"])
def list_cache(self) -> AdbResult:
"""Read-only listing of the app cache dir (for verification/logging)."""
return self.run(["shell", "run-as", self.package, "ls", "-la", "cache"])
# -- input / UI ---------------------------------------------------------- #
def input_tap(self, x: int, y: int) -> AdbResult:
return self.run(["shell", "input", "tap", str(x), str(y)])
def input_swipe(self, x1: int, y1: int, x2: int, y2: int, ms: int = 300) -> AdbResult:
return self.run(
["shell", "input", "swipe", str(x1), str(y1), str(x2), str(y2), str(ms)]
)
def input_keyevent(self, keycode: str) -> AdbResult:
return self.run(["shell", "input", "keyevent", str(keycode)])
def uiautomator_dump(self) -> str:
"""Return the current window's uiautomator XML (via ``dump /dev/tty``)."""
out = self.run(["shell", "uiautomator", "dump", "/dev/tty"], timeout=90).stdout
return _extract_xml(out)
# -- screen / keyguard --------------------------------------------------- #
def stay_on(self, on: bool = True) -> AdbResult:
return self.run(["shell", "svc", "power", "stayon", "true" if on else "false"])
def wake(self) -> AdbResult:
return self.input_keyevent("KEYCODE_WAKEUP")
def dumpsys(self, service: str, *extra: str) -> AdbResult:
return self.run(["shell", "dumpsys", service, *extra])
def settings_get(self, namespace: str, key: str) -> str:
return self.run(["shell", "settings", "get", namespace, key]).stdout.strip()
# -- logcat -------------------------------------------------------------- #
def clear_logcat(self) -> AdbResult:
return self.run(["shell", "logcat", "-c"])
def start_logcat(self, out_file) -> Optional[subprocess.Popen]:
"""Start streaming the full logcat to an already-open file handle.
Returns the :class:`subprocess.Popen` (or ``None`` in dry-run). Stop it with
:meth:`stop_logcat`.
"""
args = ["logcat", "-b", "all", "-v", "threadtime"]
assert_safe(self.package, args)
argv = self._argv(args)
if self.dry_run:
self._log(f"[dry-run] {' '.join(argv)} > <session-raw.log>")
return None
self._log(f"$ {' '.join(argv)} > <session-raw.log>")
return subprocess.Popen(argv, stdout=out_file, stderr=subprocess.DEVNULL)
def settle(self, seconds: float) -> None:
"""Sleep to let device state settle -- a no-op in dry-run so previews are instant."""
if not self.dry_run and seconds > 0:
time.sleep(seconds)
@staticmethod
def stop_logcat(proc: Optional[subprocess.Popen]) -> None:
if proc is None:
return
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
# --------------------------------------------------------------------------- #
# Pure parsers / helpers (unit-tested)
# --------------------------------------------------------------------------- #
def parse_devices(output: str) -> List[str]:
"""Parse ``adb devices`` output into a list of ready serials (state ``device``)."""
serials: List[str] = []
for line in output.splitlines():
line = line.strip()
if not line or line.startswith("List of devices"):
continue
parts = line.split()
if len(parts) >= 2 and parts[1] == "device":
serials.append(parts[0])
return serials
_AM_FIELD_RE = re.compile(r"^\s*(TotalTime|WaitTime|ThisTime):\s*(\d+)\s*$")
def parse_am_start(output: str) -> dict:
"""Extract ``TotalTime`` / ``WaitTime`` / ``ThisTime`` (ms) from ``am start -W`` output."""
result: dict = {}
for line in output.splitlines():
m = _AM_FIELD_RE.match(line)
if m:
result[m.group(1)] = int(m.group(2))
return result
def _extract_xml(raw: str) -> str:
"""Pull the ``<?xml ...</hierarchy>`` payload out of ``uiautomator dump /dev/tty`` output."""
start = raw.find("<?xml")
if start == -1:
start = raw.find("<hierarchy")
end = raw.rfind("</hierarchy>")
if start == -1 or end == -1:
return raw.strip()
return raw[start : end + len("</hierarchy>")]
+422
View File
@@ -0,0 +1,422 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""breadcrumbs.py -- pure parser for LibreMail on-device perf breadcrumbs.
This module is the *core* of the device-testing harness and is intentionally free of any
I/O, ``adb``, or device dependency so it can be unit-tested against the saved logcat
captures from the manual perf run (see ``tests/test_breadcrumbs.py`` and
``tests/fixtures/perf-extract-sample.log``).
It parses two things:
1. A raw ``adb logcat -v threadtime`` line into its fields (:func:`parse_logcat_line`).
2. The message payload of the four perf tags into typed events
(:func:`parse_breadcrumb` and the per-tag helpers).
Breadcrumb formats (verbatim from the app source, all PII-free):
* ``ImapPerf: <op> connect=<ms>ms work=<ms>ms live=<N>``
-- ``ImapClient`` generic per-operation timing; ``op`` is a short label
(``prefetch-body``, ``backfill-page``, ``body-fetch``, ``imap`` ...).
* ``ImapPerf: body-fetch select=<ms>ms body=<ms>ms flag=<ms>ms rfc822=<n>B chars=<n> att=<n>``
-- ``ImapClient`` detailed body-fetch phase split (emitted just before the generic
``body-fetch connect=.. work=.. live=..`` line for the same fetch).
* ``MailReader: openMessage <acctRef> folder=<label> fetchedBody=<bool> took=<ms>ms``
-- ``MailRepositoryImpl`` reader-open; ``fetchedBody=true`` means a real network body
fetch happened (i.e. the message was *not* cached).
* ``Reader: reader ready took=<ms>ms html=<bool> inline=<n>``
-- ``ReaderViewModel`` spinner-to-content time.
* ``MailBackfiller: backfill <acctRef> folder=<label> pages=<n> complete=<bool>``
* ``MailBackfiller: backfill slice: maxBatches=<n>``
* ``MailBackfiller: backfill slice done: moreWork=<bool>``
The threadtime tag column is space-padded by logcat (e.g. ``Reader :``); the parser
tolerates that and reports the trimmed tag (``Reader``).
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Iterable, Iterator, List, Optional, Union
# Tags that make up the filtered perf extract (mirrors the manual
# `grep -E 'ImapPerf|MailReader|Reader|MailBackfiller'`). Matched on the *tag* column so a
# stray mention of one of these words inside another tag's message is not miscounted.
PERF_TAGS = ("ImapPerf", "MailReader", "Reader", "MailBackfiller")
# --------------------------------------------------------------------------- #
# Raw logcat line (``-v threadtime``)
# --------------------------------------------------------------------------- #
_THREADTIME_RE = re.compile(
r"^(?P<date>\d{2}-\d{2})\s+"
r"(?P<time>\d{2}:\d{2}:\d{2}\.\d{3})\s+"
r"(?P<pid>\d+)\s+"
r"(?P<tid>\d+)\s+"
r"(?P<level>[VDIWEFS])\s+"
r"(?P<tag>[^:]+?)\s*:\s?"
r"(?P<message>.*)$"
)
@dataclass(frozen=True)
class LogLine:
"""A single ``adb logcat -v threadtime`` line, split into fields."""
date: str
time: str
pid: int
tid: int
level: str
tag: str
message: str
raw: str
@property
def timestamp(self) -> str:
"""`MM-DD HH:MM:SS.mmm` -- the wall-clock stamp (no year in logcat)."""
return f"{self.date} {self.time}"
def parse_logcat_line(line: str) -> Optional[LogLine]:
"""Parse one threadtime logcat line; return ``None`` if it is not one."""
m = _THREADTIME_RE.match(line.rstrip("\r\n"))
if not m:
return None
return LogLine(
date=m.group("date"),
time=m.group("time"),
pid=int(m.group("pid")),
tid=int(m.group("tid")),
level=m.group("level"),
tag=m.group("tag").strip(),
message=m.group("message").strip(),
raw=line.rstrip("\r\n"),
)
def is_perf_line(line: str) -> bool:
"""True if ``line`` is a threadtime line whose tag is one of :data:`PERF_TAGS`."""
parsed = parse_logcat_line(line)
return parsed is not None and parsed.tag in PERF_TAGS
# --------------------------------------------------------------------------- #
# Typed breadcrumb events
# --------------------------------------------------------------------------- #
class Breadcrumb:
"""Base type: every event optionally carries its source :class:`LogLine`.
A plain (non-dataclass) base so each subclass can declare its own fields with ``line``
last -- this keeps the generated ``__init__`` valid on Python 3.7+ without the
3.10-only ``kw_only`` field option (a base dataclass field with a default would force a
"non-default argument follows default argument" error in the subclasses).
"""
line: Optional[LogLine] = None
@property
def timestamp(self) -> Optional[str]:
return self.line.timestamp if self.line else None
@dataclass
class ImapPerfOp(Breadcrumb):
"""``ImapPerf: <op> connect=<ms>ms work=<ms>ms live=<N>``."""
op: str
connect_ms: int
work_ms: int
live: int
line: Optional[LogLine] = None
@dataclass
class BodyFetch(Breadcrumb):
"""``ImapPerf: body-fetch select=.. body=.. flag=.. rfc822=..B chars=.. att=..``."""
select_ms: int
body_ms: int
flag_ms: int
rfc822_bytes: int
chars: int
att: int
line: Optional[LogLine] = None
@property
def body_kb_per_s(self) -> Optional[float]:
"""Body-download throughput in decimal KB/s; ``None`` if body_ms==0.
``bytes/ms`` is already KB/s (``bytes/ms * 1000 ms/s / 1000 B/KB``), so this mirrors
the manual ``timing-tables.md`` "body KB/s" column exactly (e.g. 60457/14253 = 4.2).
"""
if self.body_ms <= 0:
return None
return self.rfc822_bytes / self.body_ms
@dataclass
class OpenMessage(Breadcrumb):
"""``MailReader: openMessage <ref> folder=<label> fetchedBody=<bool> took=<ms>ms``."""
account_ref: str
folder: str
fetched_body: bool
took_ms: int
line: Optional[LogLine] = None
@dataclass
class ReaderReady(Breadcrumb):
"""``Reader: reader ready took=<ms>ms html=<bool> inline=<n>``."""
took_ms: int
html: bool
inline: int
line: Optional[LogLine] = None
@dataclass
class BackfillProgress(Breadcrumb):
"""``MailBackfiller: backfill <ref> folder=<label> pages=<n> complete=<bool>``."""
account_ref: str
folder: str
pages: int
complete: bool
line: Optional[LogLine] = None
@dataclass
class BackfillSliceStart(Breadcrumb):
"""``MailBackfiller: backfill slice: maxBatches=<n>``."""
max_batches: int
line: Optional[LogLine] = None
@dataclass
class BackfillSliceDone(Breadcrumb):
"""``MailBackfiller: backfill slice done: moreWork=<bool>``."""
more_work: bool
line: Optional[LogLine] = None
Event = Union[
ImapPerfOp,
BodyFetch,
OpenMessage,
ReaderReady,
BackfillProgress,
BackfillSliceStart,
BackfillSliceDone,
]
# --------------------------------------------------------------------------- #
# Message-payload parsers (pure; operate on the trimmed message string)
# --------------------------------------------------------------------------- #
def _to_bool(text: str) -> bool:
return text == "true"
_IMAP_OP_RE = re.compile(
r"^(?P<op>\S+)\s+connect=(?P<connect>\d+)ms\s+work=(?P<work>\d+)ms\s+live=(?P<live>\d+)$"
)
_BODY_FETCH_RE = re.compile(
r"^body-fetch\s+select=(?P<select>\d+)ms\s+body=(?P<body>\d+)ms\s+flag=(?P<flag>\d+)ms\s+"
r"rfc822=(?P<bytes>\d+)B\s+chars=(?P<chars>\d+)\s+att=(?P<att>\d+)$"
)
_OPEN_MSG_RE = re.compile(
r"^openMessage\s+(?P<ref>\S+)\s+folder=(?P<folder>\S+)\s+"
r"fetchedBody=(?P<fetched>true|false)\s+took=(?P<took>\d+)ms$"
)
_READER_READY_RE = re.compile(
r"^reader ready took=(?P<took>\d+)ms\s+html=(?P<html>true|false)\s+inline=(?P<inline>\d+)$"
)
_BACKFILL_PROGRESS_RE = re.compile(
r"^backfill\s+(?P<ref>\S+)\s+folder=(?P<folder>\S+)\s+pages=(?P<pages>\d+)\s+"
r"complete=(?P<complete>true|false)$"
)
_BACKFILL_SLICE_START_RE = re.compile(r"^backfill slice:\s+maxBatches=(?P<n>\d+)$")
_BACKFILL_SLICE_DONE_RE = re.compile(r"^backfill slice done:\s+moreWork=(?P<more>true|false)$")
def parse_imap_perf(message: str) -> Optional[Union[ImapPerfOp, BodyFetch]]:
"""Parse an ``ImapPerf`` message (either the detailed body-fetch or the generic op)."""
m = _BODY_FETCH_RE.match(message)
if m:
return BodyFetch(
select_ms=int(m.group("select")),
body_ms=int(m.group("body")),
flag_ms=int(m.group("flag")),
rfc822_bytes=int(m.group("bytes")),
chars=int(m.group("chars")),
att=int(m.group("att")),
)
m = _IMAP_OP_RE.match(message)
if m:
return ImapPerfOp(
op=m.group("op"),
connect_ms=int(m.group("connect")),
work_ms=int(m.group("work")),
live=int(m.group("live")),
)
return None
def parse_open_message(message: str) -> Optional[OpenMessage]:
"""Parse a ``MailReader`` ``openMessage`` message."""
m = _OPEN_MSG_RE.match(message)
if not m:
return None
return OpenMessage(
account_ref=m.group("ref"),
folder=m.group("folder"),
fetched_body=_to_bool(m.group("fetched")),
took_ms=int(m.group("took")),
)
def parse_reader_ready(message: str) -> Optional[ReaderReady]:
"""Parse a ``Reader`` ``reader ready`` message."""
m = _READER_READY_RE.match(message)
if not m:
return None
return ReaderReady(
took_ms=int(m.group("took")),
html=_to_bool(m.group("html")),
inline=int(m.group("inline")),
)
def parse_backfill(
message: str,
) -> Optional[Union[BackfillProgress, BackfillSliceStart, BackfillSliceDone]]:
"""Parse any of the three ``MailBackfiller`` messages."""
m = _BACKFILL_PROGRESS_RE.match(message)
if m:
return BackfillProgress(
account_ref=m.group("ref"),
folder=m.group("folder"),
pages=int(m.group("pages")),
complete=_to_bool(m.group("complete")),
)
m = _BACKFILL_SLICE_START_RE.match(message)
if m:
return BackfillSliceStart(max_batches=int(m.group("n")))
m = _BACKFILL_SLICE_DONE_RE.match(message)
if m:
return BackfillSliceDone(more_work=_to_bool(m.group("more")))
return None
# Dispatch table keyed by the trimmed logcat tag.
_TAG_PARSERS = {
"ImapPerf": parse_imap_perf,
"MailReader": parse_open_message,
"Reader": parse_reader_ready,
"MailBackfiller": parse_backfill,
}
def parse_breadcrumb(line: str) -> Optional[Event]:
"""Parse a full threadtime logcat line into a typed :data:`Event` (or ``None``).
The returned event carries its source :class:`LogLine` on ``.line`` so callers can
order and time-box events. Lines that are not perf breadcrumbs return ``None``.
"""
log = parse_logcat_line(line)
if log is None:
return None
parser = _TAG_PARSERS.get(log.tag)
if parser is None:
return None
event = parser(log.message)
if event is None:
return None
event.line = log
return event
def iter_events(lines: Iterable[str]) -> Iterator[Event]:
"""Yield every parseable breadcrumb :data:`Event` from an iterable of logcat lines."""
for line in lines:
event = parse_breadcrumb(line)
if event is not None:
yield event
# --------------------------------------------------------------------------- #
# Correlation -- group the breadcrumbs of one message-open together
# --------------------------------------------------------------------------- #
@dataclass
class OpenSample:
"""One reader-open, correlating the breadcrumbs the app emits for a single open.
Mirrors a row of the manual ``timing-tables.md``: the ``openMessage`` marker plus the
``body-fetch`` detail/op that preceded it and the ``reader ready`` that followed.
"""
open_message: OpenMessage
body_fetch: Optional[BodyFetch] = None
body_fetch_op: Optional[ImapPerfOp] = None
reader_ready: Optional[ReaderReady] = None
@property
def account_ref(self) -> str:
return self.open_message.account_ref
@property
def cached(self) -> bool:
"""A cached open did not fetch the body over the network."""
return not self.open_message.fetched_body
@property
def took_ms(self) -> int:
return self.open_message.took_ms
@property
def rfc822_bytes(self) -> Optional[int]:
return self.body_fetch.rfc822_bytes if self.body_fetch else None
@property
def body_kb_per_s(self) -> Optional[float]:
return self.body_fetch.body_kb_per_s if self.body_fetch else None
def correlate_opens(events: Iterable[Event]) -> List[OpenSample]:
"""Group a flat, time-ordered event stream into :class:`OpenSample` records.
Strategy (matches how the breadcrumbs interleave in a real capture): keep the most
recent ``body-fetch`` detail and generic ``body-fetch`` op seen; when an
``openMessage`` marker arrives, attach those (consuming them so they are not reused);
the first ``reader ready`` after the marker attaches to it.
"""
samples: List[OpenSample] = []
pending_detail: Optional[BodyFetch] = None
pending_op: Optional[ImapPerfOp] = None
open_awaiting_reader: Optional[OpenSample] = None
for event in events:
if isinstance(event, BodyFetch):
pending_detail = event
elif isinstance(event, ImapPerfOp) and event.op == "body-fetch":
pending_op = event
elif isinstance(event, OpenMessage):
sample = OpenSample(open_message=event)
if event.fetched_body:
sample.body_fetch = pending_detail
sample.body_fetch_op = pending_op
pending_detail = None
pending_op = None
samples.append(sample)
open_awaiting_reader = sample
elif isinstance(event, ReaderReady):
if open_awaiting_reader is not None and open_awaiting_reader.reader_ready is None:
open_awaiting_reader.reader_ready = event
open_awaiting_reader = None
return samples
+258
View File
@@ -0,0 +1,258 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""perf_harness.py -- LibreMail on-device performance-test harness (CLI entry point).
Replicates the manual on-device perf methodology (2026-07-05, Pixel 10 Pro XL) as a
repeatable, cross-platform, standard-library-only tool. Run one scenario at a time:
python scripts/device-testing/perf_harness.py cold-open [opts]
python scripts/device-testing/perf_harness.py message-open [opts]
python scripts/device-testing/perf_harness.py back-nav [opts]
python scripts/device-testing/perf_harness.py prefetch-ab [opts]
python scripts/device-testing/perf_harness.py cross-provider [opts]
Common options: ``--serial`` (auto-detected if exactly one device), ``--count/-n``,
``--out``, ``--package``, ``--component``, ``--adb``, and ``--dry-run`` (print the exact
command plan without touching device state -- use this to review a run before it happens).
Device safety is enforced by :mod:`adb` (allow-list of adb subcommands + deny-list on shell
commands; the only sanctioned mutation is clearing LibreMail's own ``cache/``). See the
README for the full guarantees and the required device state.
"""
from __future__ import annotations
import argparse
import datetime as _dt
import os
import sys
import time
from typing import List, Optional
# Flat-layout imports: this file's directory is on sys.path[0] when run as a script, and the
# tests inject it explicitly. (The dir name contains a hyphen, so it is not an importable
# package -- hence flat modules rather than `python -m`.)
import breadcrumbs
import report
import scenarios
from adb import Adb, DEFAULT_COMPONENT, DEFAULT_PACKAGE
SCENARIOS = ("cold-open", "message-open", "back-nav", "prefetch-ab", "cross-provider")
_DEFAULT_COUNTS = {
"cold-open": 5,
"message-open": 6,
"back-nav": 6,
"prefetch-ab": 3,
"cross-provider": 8,
}
def _make_logger(log_path: Optional[str]):
handle = open(log_path, "a", encoding="utf-8") if log_path else None
def log(msg: str) -> None:
stamp = _dt.datetime.now().strftime("%H:%M:%S")
line = f"[{stamp}] {msg}"
print(line, flush=True)
if handle:
handle.write(line + "\n")
handle.flush()
return log, handle
def resolve_serial(adb_path: str, requested: Optional[str]) -> Optional[str]:
"""Return the serial to use, auto-detecting when exactly one device is attached."""
probe = Adb(serial=None, adb_path=adb_path)
serials = probe.devices()
if requested:
if serials and requested not in serials:
print(
f"warning: requested serial {requested!r} not in attached devices {serials}",
file=sys.stderr,
)
return requested
if len(serials) == 1:
return serials[0]
if not serials:
raise SystemExit("no devices attached; connect one or pass --serial")
raise SystemExit(f"multiple devices attached {serials}; pass --serial to choose one")
def _run_dir(out_root: str, scenario: str) -> str:
stamp = _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
path = os.path.join(out_root, f"{stamp}-{scenario}")
os.makedirs(path, exist_ok=True)
return path
def _write_filtered_extract(raw_path: str, extract_path: str) -> int:
"""Write the ImapPerf/MailReader/Reader/MailBackfiller subset of the raw log; return count."""
if not os.path.exists(raw_path):
return 0
count = 0
with open(raw_path, "r", encoding="utf-8", errors="replace") as src, open(
extract_path, "w", encoding="utf-8", newline="\n"
) as dst:
for line in src:
if breadcrumbs.is_perf_line(line):
dst.write(line if line.endswith("\n") else line + "\n")
count += 1
return count
def _render_sections(scenario: str, results, adb: Adb, count: int) -> List[str]:
if scenario == "cold-open":
return [report.render_cold_open(results)]
if scenario == "message-open":
return [report.render_message_open("Message open (uncached)", results)]
if scenario == "back-nav":
return [report.render_back_nav(results)]
if scenario == "prefetch-ab":
return [
report.render_message_open(
"Condition A - Fetch all on Wi-Fi (prefetch ON)", results["conditionA"]
),
report.render_message_open(
"Condition B - Always on-demand (prefetch OFF)", results["conditionB"]
),
_ab_comparison(results),
]
if scenario == "cross-provider":
sections = []
for ref, rows in sorted(results.items()):
sections.append(report.render_message_open(f"Provider {ref}", rows))
if not sections:
sections.append("## Cross-provider\n\nNo opens captured.\n")
return sections
return []
def _ab_comparison(results: dict) -> str:
def median_took(rows):
vals = [r.took_ms for r in rows if not r.skipped and not r.cached and r.took_ms]
return report.aggregate(vals).median
a = median_took(results["conditionA"])
b = median_took(results["conditionB"])
return (
"## Prefetch A/B comparison\n\n"
f"- Condition A (prefetch ON) median openMessage: {report._ms(a)} ms\n"
f"- Condition B (prefetch OFF) median openMessage: {report._ms(b)} ms\n"
)
def _run_scenario(scenario: str, adb: Adb, tailer, args, log):
package, component, count = args.package, args.component, args.count
if scenario == "cold-open":
return scenarios.cold_open(adb, component, count, log)
if scenario == "message-open":
return scenarios.message_open(adb, package, tailer, count, log)
if scenario == "back-nav":
return scenarios.back_nav(adb, package, count, log)
if scenario == "prefetch-ab":
return scenarios.prefetch_ab(adb, package, component, tailer, count, log)
if scenario == "cross-provider":
return scenarios.cross_provider(adb, package, tailer, count, log)
raise SystemExit(f"unknown scenario {scenario!r}")
def build_arg_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="perf_harness.py",
description="LibreMail on-device performance-test harness (stdlib-only).",
)
p.add_argument("scenario", choices=SCENARIOS, help="which scenario to run")
p.add_argument("--serial", help="device serial (auto-detected if exactly one attached)")
p.add_argument("--package", default=DEFAULT_PACKAGE, help="target app package")
p.add_argument("--component", default=DEFAULT_COMPONENT, help="launcher component")
p.add_argument("--adb", default="adb", dest="adb_path", help="path to the adb executable")
p.add_argument(
"-n", "--count", type=int, default=None,
help="samples/runs (per condition for prefetch-ab); scenario-specific default",
)
p.add_argument(
"--out", default=os.path.join(os.getcwd(), "device-perf-runs"),
help="output root; a timestamped subdir is created per run",
)
p.add_argument(
"--dry-run", action="store_true",
help="print the command plan without changing device state",
)
return p
def main(argv: Optional[List[str]] = None) -> int:
args = build_arg_parser().parse_args(argv)
if args.count is None:
args.count = _DEFAULT_COUNTS[args.scenario]
serial = None if args.dry_run else resolve_serial(args.adb_path, args.serial)
if args.dry_run and args.serial:
serial = args.serial
run_dir = _run_dir(args.out, args.scenario)
driver_log_path = os.path.join(run_dir, "driver.log")
log, log_handle = _make_logger(driver_log_path)
raw_path = os.path.join(run_dir, "session-raw.log")
log(f"scenario={args.scenario} serial={serial} count={args.count} dry_run={args.dry_run}")
log(f"run dir: {run_dir}")
adb = Adb(
serial=serial,
package=args.package,
adb_path=args.adb_path,
dry_run=args.dry_run,
logger=log,
)
logcat_proc = None
raw_handle = None
tailer: scenarios.LogTailer = scenarios._NullTailer()
try:
if not args.dry_run:
adb.clear_logcat()
# Binary handle: the logcat child writes raw bytes to this fd.
raw_handle = open(raw_path, "wb")
logcat_proc = adb.start_logcat(raw_handle)
tailer = scenarios.LogTailer(raw_path)
scenarios.ensure_awake(adb, log)
time.sleep(1.0)
results = _run_scenario(args.scenario, adb, tailer, args, log)
sections = _render_sections(args.scenario, results, adb, args.count)
finally:
Adb.stop_logcat(logcat_proc)
if raw_handle:
raw_handle.close()
if not args.dry_run:
adb.stay_on(False) # restore default screen-timeout behaviour
if log_handle:
log_handle.flush()
metadata = {
"scenario": args.scenario,
"timestamp": _dt.datetime.now().isoformat(timespec="seconds"),
"serial": serial or "(dry-run)",
"package": args.package,
"count": args.count,
}
document = report.build_document(metadata, sections)
tables_path = os.path.join(run_dir, "timing-tables.md")
with open(tables_path, "w", encoding="utf-8", newline="\n") as fh:
fh.write(document)
extract_count = 0
if not args.dry_run:
extract_count = _write_filtered_extract(
raw_path, os.path.join(run_dir, "perf-extract.log")
)
log(f"wrote {tables_path} (filtered {extract_count} breadcrumb lines)")
if log_handle:
log_handle.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""report.py -- aggregate scenario samples and render ``timing-tables.md``.
Pure and testable: given the samples a scenario collected, it produces the same
per-scenario markdown tables + aggregates as the hand-written ``timing-tables.md`` from the
manual run. No device or I/O dependency beyond writing the final file.
"""
from __future__ import annotations
import statistics
from dataclasses import dataclass
from typing import List, Optional, Sequence
from breadcrumbs import OpenSample
# The floor below which adb uiautomator-dump timings cannot resolve an in-app transition
# (see perf_summary.md: nav/back were dominated by the ~2.5-3 s dump latency).
UIAUTOMATOR_LATENCY_FLOOR_MS = 3000
# --------------------------------------------------------------------------- #
# Stats + markdown helpers
# --------------------------------------------------------------------------- #
@dataclass
class Aggregate:
n: int
mean: Optional[float]
median: Optional[float]
minimum: Optional[int]
maximum: Optional[int]
def aggregate(values: Sequence[float]) -> Aggregate:
"""Summarise a list of numbers; safe on an empty list."""
vals = [v for v in values if v is not None]
if not vals:
return Aggregate(0, None, None, None, None)
return Aggregate(
n=len(vals),
mean=statistics.fmean(vals),
median=statistics.median(vals),
minimum=min(vals),
maximum=max(vals),
)
def md_table(headers: Sequence[str], rows: Sequence[Sequence[object]]) -> str:
"""Render a GitHub-flavoured markdown table."""
head = "| " + " | ".join(str(h) for h in headers) + " |"
sep = "| " + " | ".join("---" for _ in headers) + " |"
body = [
"| " + " | ".join("" if c is None else str(c) for c in row) + " |" for row in rows
]
return "\n".join([head, sep, *body])
def _ms(value: Optional[float]) -> str:
return "" if value is None else f"{round(value)}"
# --------------------------------------------------------------------------- #
# Cold open
# --------------------------------------------------------------------------- #
@dataclass
class ColdOpenSample:
run: int
total_time_ms: Optional[int]
wait_time_ms: Optional[int]
note: str = ""
def render_cold_open(samples: Sequence[ColdOpenSample]) -> str:
rows: List[Sequence[object]] = []
for s in samples:
rows.append([s.run, _ms(s.total_time_ms), _ms(s.wait_time_ms), s.note])
total_agg = aggregate([s.total_time_ms for s in samples if s.total_time_ms is not None])
wait_agg = aggregate([s.wait_time_ms for s in samples if s.wait_time_ms is not None])
rows.append(
[
"**mean**",
f"**{_ms(total_agg.mean)}**",
f"**{_ms(wait_agg.mean)}**",
f"median {_ms(total_agg.median)} / {_ms(wait_agg.median)}",
]
)
table = md_table(["Run", "TotalTime (ms)", "WaitTime (ms)", "note"], rows)
return (
"## Cold open (am start -W, cache cleared each run)\n\n"
+ table
+ "\n\nColdest run is the first post-clear launch (class-load/JIT); steady state is lower.\n"
)
# --------------------------------------------------------------------------- #
# Message open (reader body-load)
# --------------------------------------------------------------------------- #
@dataclass
class ReaderOpenRow:
"""A flattened, render-ready reader-open row (mirrors a timing-tables.md line)."""
index: int
label: str
account_ref: str = ""
cached: bool = False
took_ms: Optional[int] = None
reader_ready_ms: Optional[int] = None
connect_ms: Optional[int] = None
work_ms: Optional[int] = None
select_ms: Optional[int] = None
body_ms: Optional[int] = None
flag_ms: Optional[int] = None
live: Optional[int] = None
rfc822_bytes: Optional[int] = None
body_kb_per_s: Optional[float] = None
skipped: bool = False
reason: str = ""
@classmethod
def from_open_sample(
cls, sample: OpenSample, index: int, label: str
) -> "ReaderOpenRow":
bf = sample.body_fetch
op = sample.body_fetch_op
return cls(
index=index,
label=label,
account_ref=sample.account_ref,
cached=sample.cached,
took_ms=sample.took_ms,
reader_ready_ms=sample.reader_ready.took_ms if sample.reader_ready else None,
connect_ms=op.connect_ms if op else None,
work_ms=op.work_ms if op else None,
select_ms=bf.select_ms if bf else None,
body_ms=bf.body_ms if bf else None,
flag_ms=bf.flag_ms if bf else None,
live=op.live if op else None,
rfc822_bytes=bf.rfc822_bytes if bf else None,
body_kb_per_s=sample.body_kb_per_s,
)
_OPEN_HEADERS = [
"#",
"message",
"cached",
"rfc822 B",
"openMessage took",
"reader ready",
"connect",
"work",
"select",
"body-dl",
"flag",
"live",
"body KB/s",
]
def _open_row_cells(row: ReaderOpenRow) -> Sequence[object]:
if row.skipped:
return [
row.index,
row.label,
"-",
f"SKIPPED: {row.reason}",
"",
"",
"",
"",
"",
"",
"",
"",
"",
]
kbps = "" if row.body_kb_per_s is None else f"{row.body_kb_per_s:.1f}"
return [
row.index,
row.label,
"yes" if row.cached else "no",
row.rfc822_bytes if row.rfc822_bytes is not None else "",
_fmt_ms(row.took_ms),
_fmt_ms(row.reader_ready_ms),
_ms(row.connect_ms),
_ms(row.work_ms),
_ms(row.select_ms),
_ms(row.body_ms),
_ms(row.flag_ms),
_ms(row.live),
kbps,
]
def _fmt_ms(value: Optional[int]) -> str:
return "" if value is None else f"{value} ms"
def render_message_open(title: str, rows: Sequence[ReaderOpenRow]) -> str:
table = md_table(_OPEN_HEADERS, [_open_row_cells(r) for r in rows])
uncached = [r for r in rows if not r.skipped and not r.cached and r.took_ms is not None]
agg = aggregate([r.took_ms for r in uncached])
lines = [f"## {title}", "", table, ""]
if agg.n:
lines.append(
f"Uncached opens: n={agg.n}, openMessage median "
f"{_ms(agg.median)} ms, mean {_ms(agg.mean)} ms, "
f"range {agg.minimum}-{agg.maximum} ms."
)
skipped = [r for r in rows if r.skipped]
if skipped:
lines.append(f"Skipped samples: {len(skipped)} (see rows above).")
return "\n".join(lines) + "\n"
# --------------------------------------------------------------------------- #
# Back navigation
# --------------------------------------------------------------------------- #
@dataclass
class BackNavSample:
index: int
back_ms: Optional[int]
note: str = ""
def render_back_nav(samples: Sequence[BackNavSample]) -> str:
rows = [[s.index, _fmt_ms(s.back_ms), s.note] for s in samples]
agg = aggregate([s.back_ms for s in samples if s.back_ms is not None])
table = md_table(["#", "back (reader->mailbox)", "note"], rows)
caveat = (
f"\n\n**Caveat:** these are dominated by the ~{UIAUTOMATOR_LATENCY_FLOOR_MS} ms "
"uiautomator-dump latency floor; true in-app back is sub-second and not precisely "
"measurable via adb UI polling under load (see perf_summary.md)."
)
summary = "" if not agg.n else f"\n\nBack: n={agg.n}, mean {_ms(agg.mean)} ms, median {_ms(agg.median)} ms."
return "## Reader -> mailbox (back)\n\n" + table + summary + caveat + "\n"
# --------------------------------------------------------------------------- #
# Document assembly
# --------------------------------------------------------------------------- #
def render_header(metadata: dict) -> str:
lines = ["# LibreMail device perf run", ""]
for key, value in metadata.items():
lines.append(f"- **{key}:** {value}")
lines.append("")
return "\n".join(lines)
def build_document(metadata: dict, sections: Sequence[str]) -> str:
parts = [render_header(metadata), *sections]
return "\n".join(parts).rstrip() + "\n"
+445
View File
@@ -0,0 +1,445 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""scenarios.py -- the on-device performance scenarios.
Each scenario drives LibreMail through the guarded :class:`adb.Adb` wrapper, times the
behaviour primarily from the on-device breadcrumbs (with uiautomator as a fallback ready
signal), and returns render-ready samples for :mod:`report`.
Timing philosophy (from the manual run): message-open and reader-ready times come from the
``MailReader`` / ``Reader`` / ``ImapPerf`` breadcrumbs -- adb UI polling only tells us *when*
the content is ready so we can move on, and back-nav is explicitly dump-latency-bound.
Safety: every device call goes through :class:`adb.Adb`, so its allow/deny guardrails apply.
Every uiautomator/input step is guarded against the keyguard and against a foreign app being
in the foreground; a sample taken against either is skipped, not measured.
``--dry-run`` executes one representative pass per scenario -- issuing the canonical command
sequence (so the plan is auditable) without looping on a live UI.
"""
from __future__ import annotations
import os
import time
from typing import Callable, List, Optional
import breadcrumbs
import uidump
from adb import Adb, parse_am_start
from report import BackNavSample, ColdOpenSample, ReaderOpenRow
# Message-open can stall 30-75 s behind the spinner (Gmail throttle), so allow generous
# headroom before giving up on a single open.
OPEN_TIMEOUT_S = 150.0
POLL_INTERVAL_S = 2.0
SETTLE_S = 1.5
# Fetch-policy option labels (from res/values/strings.xml) used to drive the A/B toggle.
FETCH_WIFI_LABEL = "Fetch all on Wi-Fi" # prefetch ON (Condition A / WIFI_ONLY)
FETCH_ON_DEMAND_LABEL = "Always on-demand" # prefetch OFF (Condition B / ON_DEMAND)
# --------------------------------------------------------------------------- #
# Live-logcat tailing (per-sample breadcrumb extraction)
# --------------------------------------------------------------------------- #
class LogTailer:
"""Reads newly-appended text from the growing session logcat file since the last mark."""
def __init__(self, path: str) -> None:
self.path = path
self._offset = 0
def mark(self) -> None:
"""Set the read cursor to the current end of file."""
self._offset = os.path.getsize(self.path) if os.path.exists(self.path) else 0
def read_new(self) -> str:
if not os.path.exists(self.path):
return ""
with open(self.path, "r", encoding="utf-8", errors="replace") as fh:
fh.seek(self._offset)
data = fh.read()
self._offset = fh.tell()
return data
class _NullTailer(LogTailer):
"""A tailer that yields nothing -- used in dry-run so scenarios need no live log."""
def __init__(self) -> None: # noqa: D401 - see base
super().__init__(path="")
def mark(self) -> None:
pass
def read_new(self) -> str:
return ""
# --------------------------------------------------------------------------- #
# UI helpers (all guarded)
# --------------------------------------------------------------------------- #
def dump_ui(adb: Adb) -> Optional[uidump.UiNode]:
"""Dump + parse the current UI; ``None`` in dry-run or on an unparseable dump."""
if adb.dry_run:
return None
xml = adb.uiautomator_dump()
if not xml or "<hierarchy" not in xml:
return None
try:
return uidump.parse_dump(xml)
except Exception: # pragma: no cover - malformed dump under load
return None
def ensure_awake(adb: Adb, log: Callable[[str], None]) -> None:
"""Keep the screen on and awake for the run (restored by the caller afterwards)."""
adb.stay_on(True)
adb.wake()
def guard_ready(adb: Adb, package: str, log: Callable[[str], None]) -> Optional[uidump.UiNode]:
"""Return the current UI iff it is our app and not the keyguard; else try to recover.
Returns ``None`` if, after a wake attempt, the sample is still against the lockscreen or
a foreign app -- the caller must skip that sample rather than measure garbage.
"""
root = dump_ui(adb)
if root is None:
return None
if uidump.is_lockscreen(root):
log("keyguard detected; waking and re-checking")
adb.wake()
time.sleep(SETTLE_S)
root = dump_ui(adb)
if root is None or uidump.is_lockscreen(root):
log("still on keyguard after wake; skipping sample")
return None
if not uidump.is_app_foreground(root, package):
log(f"foreground is {uidump.foreground_package(root)!r}, not {package!r}; skipping")
return None
return root
def goto_mailbox(adb: Adb, package: str, log: Callable[[str], None]) -> Optional[uidump.UiNode]:
"""Ensure the mailbox list is showing (press Back out of the reader if needed)."""
root = guard_ready(adb, package, log)
if root is None:
return None
if uidump.is_reader(root, package):
adb.input_keyevent("KEYCODE_BACK")
time.sleep(SETTLE_S)
root = guard_ready(adb, package, log)
return root
def wait_for_reader_ready(adb: Adb, package: str, log: Callable[[str], None]) -> bool:
"""Poll until the reader has loaded its body (no spinner) or timeout. True if loaded."""
deadline = time.monotonic() + OPEN_TIMEOUT_S
while time.monotonic() < deadline:
time.sleep(POLL_INTERVAL_S)
root = dump_ui(adb)
if root is None:
continue
if uidump.is_lockscreen(root):
log("keyguard appeared during open; sample is invalid")
return False
if uidump.is_reader(root, package) and not uidump.has_progress_bar(root):
return True
log("open timed out")
return False
# --------------------------------------------------------------------------- #
# Scenario 1: cold open
# --------------------------------------------------------------------------- #
def cold_open(
adb: Adb,
component: str,
runs: int,
log: Callable[[str], None],
settle_s: float = 2.0,
) -> List[ColdOpenSample]:
"""Force-stop + clear cache + ``am start -W`` x ``runs``; parse TotalTime/WaitTime."""
samples: List[ColdOpenSample] = []
for run in range(1, runs + 1):
adb.force_stop()
adb.clear_cache()
adb.settle(settle_s)
out = adb.start_activity(component=component, wait=True).stdout
fields = parse_am_start(out)
note = "" if fields else ("dry-run" if adb.dry_run else "no am-start timing parsed")
samples.append(
ColdOpenSample(
run=run,
total_time_ms=fields.get("TotalTime"),
wait_time_ms=fields.get("WaitTime"),
note=note,
)
)
log(f"cold-open run {run}: {fields or note}")
adb.settle(settle_s)
return samples
# --------------------------------------------------------------------------- #
# Scenario 2: message open (uncached)
# --------------------------------------------------------------------------- #
def _row_from_events(index: int, label: str, events: List[breadcrumbs.Event]) -> ReaderOpenRow:
"""Build a reader-open row from the breadcrumbs captured during one open."""
opens = breadcrumbs.correlate_opens(events)
if not opens:
return ReaderOpenRow(index=index, label=label, skipped=True, reason="no breadcrumb")
sample = opens[-1]
return ReaderOpenRow.from_open_sample(sample, index=index, label=label)
def open_one_message(
adb: Adb,
package: str,
tailer: LogTailer,
index: int,
already_opened: set,
log: Callable[[str], None],
) -> Optional[ReaderOpenRow]:
"""Open the next not-yet-opened, uncached message; return its timed row (or ``None``)."""
root = goto_mailbox(adb, package, log)
if root is None:
return ReaderOpenRow(index=index, label="?", skipped=True, reason="not on mailbox")
rows = uidump.find_message_rows(root, package)
candidates = [r for r in rows if not r.cached and r.label not in already_opened]
if not candidates:
# Scroll to reveal more of the list, then re-scan once.
adb.input_swipe(672, 2000, 672, 900, 400)
time.sleep(SETTLE_S)
root = guard_ready(adb, package, log)
if root is None:
return None
rows = uidump.find_message_rows(root, package)
candidates = [r for r in rows if not r.cached and r.label not in already_opened]
if not candidates:
log("no more uncached messages visible")
return None
target = candidates[0]
already_opened.add(target.label)
log(f"opening row#{target.index} {target.label!r} at {target.center}")
tailer.mark()
adb.input_tap(*target.center)
loaded = wait_for_reader_ready(adb, package, log)
events = list(breadcrumbs.iter_events(tailer.read_new().splitlines()))
row = _row_from_events(index, target.label, events)
if not loaded and not row.skipped:
row.reason = "ui ready-signal timed out (breadcrumb used)"
elif not loaded:
row.skipped = True
row.reason = "open timed out"
# Return to the mailbox for the next sample.
adb.input_keyevent("KEYCODE_BACK")
time.sleep(SETTLE_S)
return row
def message_open(
adb: Adb,
package: str,
tailer: LogTailer,
count: int,
log: Callable[[str], None],
) -> List[ReaderOpenRow]:
"""Open up to ``count`` distinct uncached messages one at a time."""
if adb.dry_run:
_dry_run_open_demo(adb, log)
return [ReaderOpenRow(index=1, label="<dry-run>", skipped=True, reason="dry-run")]
rows: List[ReaderOpenRow] = []
opened: set = set()
for i in range(1, count + 1):
row = open_one_message(adb, package, tailer, i, opened, log)
if row is None:
break
rows.append(row)
return rows
def _dry_run_open_demo(adb: Adb, log: Callable[[str], None]) -> None:
"""Issue the canonical message-open command shape once (dry-run only)."""
adb.uiautomator_dump()
adb.input_tap(672, 504) # tap a representative message row centre
adb.input_keyevent("KEYCODE_BACK")
# --------------------------------------------------------------------------- #
# Scenario 3: back navigation
# --------------------------------------------------------------------------- #
def back_nav(
adb: Adb,
package: str,
count: int,
log: Callable[[str], None],
) -> List[BackNavSample]:
"""Time reader -> mailbox back transitions (dump-latency-bound; see report caveat)."""
if adb.dry_run:
adb.uiautomator_dump()
adb.input_keyevent("KEYCODE_BACK")
return [BackNavSample(index=1, back_ms=None, note="dry-run")]
samples: List[BackNavSample] = []
opened: set = set()
for i in range(1, count + 1):
# Get into the reader by opening any message.
root = goto_mailbox(adb, package, log)
if root is None:
samples.append(BackNavSample(index=i, back_ms=None, note="not on mailbox"))
continue
rows = uidump.find_message_rows(root, package)
if not rows:
samples.append(BackNavSample(index=i, back_ms=None, note="no rows"))
continue
adb.input_tap(*rows[0].center)
wait_for_reader_ready(adb, package, log)
# Now time the back transition to the mailbox.
start = time.monotonic()
adb.input_keyevent("KEYCODE_BACK")
reached = _wait_until(
lambda: _is_mailbox(adb, package), timeout_s=30.0
)
elapsed_ms = round((time.monotonic() - start) * 1000)
samples.append(
BackNavSample(
index=i,
back_ms=elapsed_ms if reached else None,
note="" if reached else "did not reach mailbox",
)
)
return samples
def _is_mailbox(adb: Adb, package: str) -> bool:
root = dump_ui(adb)
return root is not None and uidump.is_app_foreground(root, package) and not uidump.is_reader(
root, package
)
def _wait_until(predicate: Callable[[], bool], timeout_s: float) -> bool:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(0.2)
return False
# --------------------------------------------------------------------------- #
# Scenario 4: prefetch A/B (fetch policy toggle)
# --------------------------------------------------------------------------- #
def set_fetch_policy(
adb: Adb,
package: str,
option_label: str,
log: Callable[[str], None],
) -> bool:
"""Navigate Settings and select a fetch-policy option by its visible label.
NOTE: no uiautomator dump of the settings screen was captured in the manual run, so the
settings-screen navigation is by on-screen text and needs live verification (documented
in the README). Returns True if the option was found and tapped.
"""
if adb.dry_run:
adb.uiautomator_dump()
adb.input_tap(1014, 2728) # bottom-nav "Settings"
adb.uiautomator_dump()
adb.input_tap(672, 1400) # representative fetch-policy option
adb.input_keyevent("KEYCODE_BACK")
return True
root = goto_mailbox(adb, package, log)
if root is None:
return False
settings_label = uidump.find_by_text(root, "Settings")
if settings_label is None:
log("no Settings entry found in bottom nav")
return False
anchor = settings_label.first_clickable_ancestor() or settings_label
if anchor.center:
adb.input_tap(*anchor.center)
time.sleep(SETTLE_S)
# Find the fetch-policy option, scrolling the settings list if necessary.
for _ in range(6):
sroot = guard_ready(adb, package, log)
if sroot is None:
return False
option = uidump.find_by_text(sroot, option_label)
if option is not None:
anchor = option.first_clickable_ancestor() or option
if anchor.center:
adb.input_tap(*anchor.center)
log(f"selected fetch policy {option_label!r}")
time.sleep(SETTLE_S)
adb.input_keyevent("KEYCODE_BACK") # back to mailbox
time.sleep(SETTLE_S)
return True
adb.input_swipe(672, 2000, 672, 900, 400)
time.sleep(SETTLE_S)
log(f"fetch-policy option {option_label!r} not found")
return False
def prefetch_ab(
adb: Adb,
package: str,
component: str,
tailer: LogTailer,
count: int,
log: Callable[[str], None],
) -> dict:
"""Run message-open under prefetch ON (Wi-Fi) vs OFF (on-demand), cache cleared between."""
result = {"conditionA": [], "conditionB": []}
log("Condition A: fetch policy = Fetch all on Wi-Fi (prefetch ON)")
set_fetch_policy(adb, package, FETCH_WIFI_LABEL, log)
_reset_for_uncached(adb, component, log)
result["conditionA"] = message_open(adb, package, tailer, count, log)
log("Condition B: fetch policy = Always on-demand (prefetch OFF)")
set_fetch_policy(adb, package, FETCH_ON_DEMAND_LABEL, log)
_reset_for_uncached(adb, component, log)
result["conditionB"] = message_open(adb, package, tailer, count, log)
return result
def _reset_for_uncached(adb: Adb, component: str, log: Callable[[str], None]) -> None:
"""Clear the cache (and restart) so subsequent opens are uncached network fetches."""
adb.force_stop()
adb.clear_cache()
adb.settle(1.0)
adb.start_activity(component=component, wait=True)
adb.settle(3.0)
# --------------------------------------------------------------------------- #
# Scenario 5: cross-provider (optional)
# --------------------------------------------------------------------------- #
def cross_provider(
adb: Adb,
package: str,
tailer: LogTailer,
count: int,
log: Callable[[str], None],
) -> dict:
"""Open ``count`` messages from the (unified) inbox and bucket the rows by account_ref.
The breadcrumbs carry the account reference (``imap:...`` vs ``outlook:...``), so a
single mixed-inbox pass yields the per-provider comparison without account switching.
"""
rows = message_open(adb, package, tailer, count, log)
buckets: dict = {}
for row in rows:
key = row.account_ref or "unknown"
buckets.setdefault(key, []).append(row)
return buckets
+2
View File
@@ -0,0 +1,2 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Test package for the LibreMail device-testing perf harness."""
@@ -0,0 +1,17 @@
07-05 17:55:38.343 10261 10423 D ImapPerf: prefetch-body connect=857ms work=2150ms live=3
07-05 17:55:54.352 10261 10425 D ImapPerf: backfill-page connect=4366ms work=71989ms live=2
07-05 17:57:38.981 10261 10425 I MailReader: openMessage imap:94058a folder=INBOX fetchedBody=false took=21ms
07-05 17:57:39.041 10261 10261 D Reader : reader ready took=82ms html=true inline=0
07-05 18:04:37.001 10261 10408 I MailBackfiller: backfill slice done: moreWork=true
07-05 18:04:37.002 10261 10408 I MailBackfiller: backfill slice: maxBatches=20
07-05 18:07:16.094 10261 15306 D ImapPerf: body-fetch select=2566ms body=14253ms flag=2356ms rfc822=60457B chars=51610 att=0
07-05 18:07:18.435 10261 15306 D ImapPerf: body-fetch connect=2624ms work=26211ms live=3
07-05 18:07:20.817 10261 15306 I MailReader: openMessage imap:94058a folder=INBOX fetchedBody=true took=31227ms
07-05 18:07:21.206 10261 10261 D Reader : reader ready took=31617ms html=true inline=0
07-05 18:07:32.607 10261 10414 D ImapPerf: backfill-page connect=656ms work=3038ms live=3
07-05 18:36:37.959 19255 21059 D MailBackfiller: backfill outlook:6b54d6 folder=INBOX pages=20 complete=false
07-05 18:36:54.941 19255 19314 D ImapPerf: body-fetch select=185ms body=1355ms flag=78ms rfc822=189086B chars=82986 att=0
07-05 18:36:55.002 19255 19314 D ImapPerf: body-fetch connect=772ms work=2018ms live=2
07-05 18:36:55.140 19255 21060 I MailReader: openMessage outlook:6b54d6 folder=INBOX fetchedBody=true took=2934ms
07-05 18:36:55.144 19255 19255 D Reader : reader ready took=2939ms html=true inline=0
07-05 18:37:11.704 19255 19314 D ImapPerf: imap connect=3156ms work=8032ms live=2
+1
View File
@@ -0,0 +1 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.google.android.deskclock" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1344,2992]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="com.google.android.deskclock" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1344,2992]" drawing-order="1" hint=""><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.google.android.deskclock" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1344,2992]" drawing-order="2" hint=""><node index="0" text="" resource-id="com.google.android.deskclock:id/action_bar_root" class="android.widget.LinearLayout" package="com.google.android.deskclock" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1344,2992]" drawing-order="1" hint=""><node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="com.google.android.deskclock" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1344,2992]" drawing-order="2" hint=""><node index="0" text="" resource-id="com.google.android.deskclock:id/content" class="android.view.ViewGroup" package="com.google.android.deskclock" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1344,2992]" drawing-order="1" hint=""><node index="0" text="" resource-id="com.google.android.deskclock:id/digital_clock" class="android.widget.FrameLayout" package="com.google.android.deskclock" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[54,1047][1290,1586]" drawing-order="18" hint=""><node index="0" text="18:00" resource-id="com.google.android.deskclock:id/auto_sizing_text_clock" class="android.widget.TextView" package="com.google.android.deskclock" content-desc="18:00" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[54,1047][1290,1586]" drawing-order="1" hint="" /></node><node index="1" text="Bae check in" resource-id="com.google.android.deskclock:id/title" class="android.widget.TextView" package="com.google.android.deskclock" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[54,1646][1290,1960]" drawing-order="19" hint="" /><node index="2" text="Snooze" resource-id="com.google.android.deskclock:id/button_tap_snooze" class="android.widget.Button" package="com.google.android.deskclock" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[126,2274][669,2562]" drawing-order="39" hint="" /><node index="3" text="Stop" resource-id="com.google.android.deskclock:id/button_tap_dismiss" class="android.widget.Button" package="com.google.android.deskclock" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[675,2274][1218,2562]" drawing-order="40" hint="" /></node></node></node></node></node></node></hierarchy>
@@ -0,0 +1 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="com.android.systemui:id/legacy_window_root" class="android.widget.FrameLayout" package="com.android.systemui" content-desc="" clickable="false" enabled="true" long-clickable="false" scrollable="false" bounds="[0,0][1344,2992]"><node index="0" text="" resource-id="com.android.systemui:id/keyguard_root_view" class="android.view.ViewGroup" package="com.android.systemui" content-desc="" clickable="false" enabled="true" long-clickable="false" scrollable="false" bounds="[0,0][1344,2992]"><node index="0" text="" resource-id="com.android.systemui:id/accessibility_actions_view" class="android.view.View" package="com.android.systemui" content-desc="Lock screen" clickable="false" enabled="true" long-clickable="true" scrollable="false" bounds="[0,216][1344,2992]" /><node index="1" text="18 02" resource-id="" class="android.widget.TextView" package="com.android.systemui" content-desc="18:02" clickable="false" enabled="true" long-clickable="false" scrollable="false" bounds="[100,454][583,620]" /><node index="2" text="" resource-id="com.android.systemui:id/device_entry_icon_fg" class="android.widget.ImageView" package="com.android.systemui" content-desc="Fingerprint sensor" clickable="false" enabled="true" long-clickable="false" scrollable="false" bounds="[561,2126][791,2356]" /><node index="3" text="Charged" resource-id="com.android.systemui:id/keyguard_indication_text" class="android.widget.TextView" package="com.android.systemui" content-desc="" clickable="false" enabled="true" long-clickable="false" scrollable="false" bounds="[16,2860][1344,2925]" /></node></node></hierarchy>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,160 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Tests for the adb safety guardrails -- the harness must refuse dangerous commands."""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import adb # noqa: E402
from adb import Adb, AdbSafetyError, assert_safe # noqa: E402
PKG = "org.libremail.app"
class TestAllowedCommands(unittest.TestCase):
def _ok(self, args):
try:
assert_safe(PKG, args)
except AdbSafetyError as exc: # pragma: no cover - failure path
self.fail(f"assert_safe wrongly rejected {args}: {exc}")
def test_lifecycle_and_input_allowed(self):
self._ok(["shell", "am", "force-stop", PKG])
self._ok(["shell", "am", "start", "-W", "-n", f"{PKG}/org.libremail.MainActivity"])
self._ok(["shell", "input", "tap", "672", "504"])
self._ok(["shell", "input", "swipe", "672", "2000", "672", "900", "400"])
self._ok(["shell", "input", "keyevent", "KEYCODE_BACK"])
self._ok(["shell", "input", "keyevent", "KEYCODE_WAKEUP"])
self._ok(["shell", "uiautomator", "dump", "/dev/tty"])
self._ok(["shell", "svc", "power", "stayon", "true"])
self._ok(["shell", "dumpsys", "gfxinfo", PKG])
self._ok(["shell", "settings", "get", "global", "airplane_mode_on"])
self._ok(["shell", "logcat", "-c"])
self._ok(["logcat", "-b", "all", "-v", "threadtime"])
self._ok(["devices"])
self._ok(["get-state"])
self._ok(["install", "-r", "app-debug.apk"])
def test_sanctioned_cache_clear_allowed(self):
self._ok(["shell", "run-as", PKG, "sh", "-c", "rm -rf cache/*"])
self._ok(["shell", "run-as", PKG, "sh", "-c", "rm -rf cache"])
self._ok(["shell", "run-as", PKG, "ls", "-la", "cache"])
def test_input_swipe_not_confused_with_wipe(self):
# 'swipe' contains the substring 'wipe'; it must still be allowed.
self._ok(["shell", "input", "swipe", "0", "0", "0", "500", "300"])
class TestDeniedCommands(unittest.TestCase):
def _deny(self, args):
with self.assertRaises(AdbSafetyError):
assert_safe(PKG, args)
def test_top_level_allowlist(self):
self._deny(["uninstall", PKG])
self._deny(["root"])
self._deny(["remount"])
self._deny(["reboot"])
self._deny(["disable-verity"])
self._deny(["emu", "kill"])
self._deny(["push", "x", "/data"])
self._deny(["pull", "/data/data/org.libremail.app/databases/x"])
def test_pm_clear_and_uninstall_denied(self):
self._deny(["shell", "pm", "clear", PKG])
self._deny(["shell", "pm", "uninstall", PKG])
self._deny(["shell", "pm", "disable", PKG])
def test_reboot_and_root_via_shell_denied(self):
self._deny(["shell", "reboot"])
self._deny(["shell", "svc", "power", "reboot"])
self._deny(["shell", "su", "-c", "reboot"])
def test_no_touching_private_dirs(self):
self._deny(["shell", "run-as", PKG, "sh", "-c", "rm -rf databases"])
self._deny(["shell", "run-as", PKG, "sh", "-c", "rm -rf shared_prefs"])
self._deny(["shell", "run-as", PKG, "sh", "-c", "rm -rf datastore"])
self._deny(["shell", "run-as", PKG, "cat", "databases/libremail.db"])
self._deny(["shell", "run-as", PKG, "ls", "files/datastore"])
def test_rm_outside_cache_denied(self):
self._deny(["shell", "run-as", PKG, "sh", "-c", "rm -rf /sdcard/x"])
self._deny(["shell", "run-as", PKG, "sh", "-c", "rm -rf cache/../databases"])
self._deny(["shell", "rm", "-rf", "/data/local/tmp"])
def test_output_redirect_denied(self):
self._deny(["shell", "echo", "x", ">", "/sdcard/y"])
def test_run_as_foreign_package_denied(self):
self._deny(["shell", "run-as", "com.other.app", "ls", "cache"])
def test_am_foreign_targets_denied(self):
self._deny(["shell", "am", "force-stop", "com.other.app"])
self._deny(["shell", "am", "start", "-n", "com.other.app/.Main"])
def test_empty_command_denied(self):
self._deny([])
class TestAdbWrapperBuildsSafeArgv(unittest.TestCase):
"""In dry-run the wrapper still validates and returns the argv it would run."""
def setUp(self):
self.adb = Adb(serial="SERIAL123", package=PKG, dry_run=True)
def test_force_stop_argv(self):
self.assertEqual(self.adb.force_stop().args, ["shell", "am", "force-stop", PKG])
def test_start_activity_argv(self):
args = self.adb.start_activity().args
self.assertEqual(args[:5], ["shell", "am", "start", "-W", "-n"])
self.assertTrue(args[5].startswith(PKG + "/"))
def test_clear_cache_argv_is_sanctioned(self):
self.assertEqual(
self.adb.clear_cache().args,
["shell", "run-as", PKG, "sh", "-c", "rm -rf cache/*"],
)
def test_argv_includes_serial(self):
full = self.adb._argv(["devices"])
self.assertEqual(full, ["adb", "-s", "SERIAL123", "devices"])
def test_wrapper_rejects_unsafe_low_level_call(self):
with self.assertRaises(AdbSafetyError):
self.adb.run(["shell", "pm", "clear", PKG])
class TestPureParsers(unittest.TestCase):
def test_parse_devices(self):
out = (
"List of devices attached\n"
"5C310DLCQ000G3\tdevice\n"
"emulator-5554\tdevice\n"
"0123456789\toffline\n"
)
self.assertEqual(adb.parse_devices(out), ["5C310DLCQ000G3", "emulator-5554"])
def test_parse_devices_empty(self):
self.assertEqual(adb.parse_devices("List of devices attached\n"), [])
def test_parse_am_start(self):
out = (
"Starting: Intent { cmp=org.libremail.app/org.libremail.MainActivity }\n"
"Status: ok\n"
"LaunchState: COLD\n"
"TotalTime: 384\n"
"WaitTime: 386\n"
)
fields = adb.parse_am_start(out)
self.assertEqual(fields["TotalTime"], 384)
self.assertEqual(fields["WaitTime"], 386)
def test_parse_am_start_missing(self):
self.assertEqual(adb.parse_am_start("Status: ok\n"), {})
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,222 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Unit tests for the breadcrumb parser -- validated against the manual run's real captures.
The fixture ``fixtures/perf-extract-sample.log`` is a verbatim slice of the manual run's
``perf-extract-ALL.log`` (plus the Outlook control), so these tests assert that the parser
reproduces the exact figures in the hand-written ``timing-tables.md``.
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import breadcrumbs as bc # noqa: E402
FIXTURE = os.path.join(os.path.dirname(__file__), "fixtures", "perf-extract-sample.log")
def _load_fixture_lines():
with open(FIXTURE, encoding="utf-8") as fh:
return fh.read().splitlines()
class TestLogcatLine(unittest.TestCase):
def test_parses_threadtime_fields(self):
line = "07-05 18:07:20.817 10261 15306 I MailReader: openMessage imap:94058a folder=INBOX fetchedBody=true took=31227ms"
parsed = bc.parse_logcat_line(line)
self.assertIsNotNone(parsed)
self.assertEqual(parsed.date, "07-05")
self.assertEqual(parsed.time, "18:07:20.817")
self.assertEqual(parsed.pid, 10261)
self.assertEqual(parsed.tid, 15306)
self.assertEqual(parsed.level, "I")
self.assertEqual(parsed.tag, "MailReader")
self.assertTrue(parsed.message.startswith("openMessage imap:94058a"))
self.assertEqual(parsed.timestamp, "07-05 18:07:20.817")
def test_tolerates_space_padded_tag(self):
# logcat pads the tag column, e.g. "Reader :".
line = "07-05 17:57:39.041 10261 10261 D Reader : reader ready took=82ms html=true inline=0"
parsed = bc.parse_logcat_line(line)
self.assertEqual(parsed.tag, "Reader")
self.assertEqual(parsed.message, "reader ready took=82ms html=true inline=0")
def test_non_threadtime_returns_none(self):
self.assertIsNone(bc.parse_logcat_line("--------- beginning of main"))
self.assertIsNone(bc.parse_logcat_line(""))
def test_is_perf_line(self):
self.assertTrue(
bc.is_perf_line("07-05 17:55:38.343 10261 10423 D ImapPerf: prefetch-body connect=857ms work=2150ms live=3")
)
self.assertFalse(
bc.is_perf_line("07-05 15:00:06.896 15192 15217 I MailSyncer: sync all: 1 accounts")
)
self.assertFalse(bc.is_perf_line("garbage"))
class TestMessageParsers(unittest.TestCase):
def test_imap_perf_generic_op(self):
ev = bc.parse_imap_perf("prefetch-body connect=857ms work=2150ms live=3")
self.assertIsInstance(ev, bc.ImapPerfOp)
self.assertEqual((ev.op, ev.connect_ms, ev.work_ms, ev.live), ("prefetch-body", 857, 2150, 3))
def test_imap_perf_backfill_page(self):
ev = bc.parse_imap_perf("backfill-page connect=4366ms work=71989ms live=2")
self.assertEqual((ev.op, ev.connect_ms, ev.work_ms, ev.live), ("backfill-page", 4366, 71989, 2))
def test_imap_perf_body_fetch_op(self):
ev = bc.parse_imap_perf("body-fetch connect=2624ms work=26211ms live=3")
self.assertIsInstance(ev, bc.ImapPerfOp)
self.assertEqual(ev.op, "body-fetch")
def test_body_fetch_detail(self):
ev = bc.parse_imap_perf(
"body-fetch select=2566ms body=14253ms flag=2356ms rfc822=60457B chars=51610 att=0"
)
self.assertIsInstance(ev, bc.BodyFetch)
self.assertEqual(ev.select_ms, 2566)
self.assertEqual(ev.body_ms, 14253)
self.assertEqual(ev.flag_ms, 2356)
self.assertEqual(ev.rfc822_bytes, 60457)
self.assertEqual(ev.chars, 51610)
self.assertEqual(ev.att, 0)
def test_body_fetch_throughput_matches_manual_table(self):
# timing-tables.md reports 4.2 KB/s for A1 and 139.5 KB/s for O1.
a1 = bc.parse_imap_perf(
"body-fetch select=2566ms body=14253ms flag=2356ms rfc822=60457B chars=51610 att=0"
)
self.assertAlmostEqual(a1.body_kb_per_s, 4.24, places=2)
o1 = bc.parse_imap_perf(
"body-fetch select=185ms body=1355ms flag=78ms rfc822=189086B chars=82986 att=0"
)
self.assertAlmostEqual(o1.body_kb_per_s, 139.5, places=1)
def test_open_message_uncached(self):
ev = bc.parse_open_message("openMessage imap:94058a folder=INBOX fetchedBody=true took=31227ms")
self.assertEqual(ev.account_ref, "imap:94058a")
self.assertEqual(ev.folder, "INBOX")
self.assertTrue(ev.fetched_body)
self.assertEqual(ev.took_ms, 31227)
def test_open_message_cached(self):
ev = bc.parse_open_message("openMessage outlook:6b54d6 folder=INBOX fetchedBody=false took=4ms")
self.assertEqual(ev.account_ref, "outlook:6b54d6")
self.assertFalse(ev.fetched_body)
self.assertEqual(ev.took_ms, 4)
def test_reader_ready(self):
ev = bc.parse_reader_ready("reader ready took=31617ms html=true inline=0")
self.assertEqual(ev.took_ms, 31617)
self.assertTrue(ev.html)
self.assertEqual(ev.inline, 0)
def test_backfill_progress(self):
ev = bc.parse_backfill("backfill outlook:6b54d6 folder=INBOX pages=20 complete=false")
self.assertIsInstance(ev, bc.BackfillProgress)
self.assertEqual(ev.account_ref, "outlook:6b54d6")
self.assertEqual(ev.pages, 20)
self.assertFalse(ev.complete)
def test_backfill_slice_start_and_done(self):
start = bc.parse_backfill("backfill slice: maxBatches=20")
self.assertIsInstance(start, bc.BackfillSliceStart)
self.assertEqual(start.max_batches, 20)
done = bc.parse_backfill("backfill slice done: moreWork=true")
self.assertIsInstance(done, bc.BackfillSliceDone)
self.assertTrue(done.more_work)
def test_unparseable_messages_return_none(self):
self.assertIsNone(bc.parse_imap_perf("nonsense here"))
self.assertIsNone(bc.parse_open_message("openMessage missing fields"))
self.assertIsNone(bc.parse_reader_ready("reader not ready"))
self.assertIsNone(bc.parse_backfill("backfill mystery"))
class TestParseBreadcrumbDispatch(unittest.TestCase):
def test_dispatch_attaches_source_line(self):
line = "07-05 18:07:18.435 10261 15306 D ImapPerf: body-fetch connect=2624ms work=26211ms live=3"
ev = bc.parse_breadcrumb(line)
self.assertIsInstance(ev, bc.ImapPerfOp)
self.assertIsNotNone(ev.line)
self.assertEqual(ev.line.pid, 10261)
self.assertEqual(ev.timestamp, "07-05 18:07:18.435")
def test_dispatch_ignores_non_perf_tags(self):
self.assertIsNone(
bc.parse_breadcrumb("07-05 15:00:06.896 15192 15217 I MailSyncer: sync all: 1 accounts")
)
class TestFixtureStream(unittest.TestCase):
def setUp(self):
self.lines = _load_fixture_lines()
self.events = list(bc.iter_events(self.lines))
def test_all_fixture_lines_are_perf_lines(self):
self.assertEqual(sum(1 for line in self.lines if bc.is_perf_line(line)), len(self.lines))
def test_every_line_parses(self):
self.assertEqual(len(self.events), len(self.lines))
def test_event_type_mix(self):
kinds = [type(e).__name__ for e in self.events]
# 3 prefetch/backfill-page ops + 2 body-fetch ops + 1 imap op = 6.
self.assertEqual(kinds.count("ImapPerfOp"), 6)
self.assertEqual(kinds.count("BodyFetch"), 2)
self.assertEqual(kinds.count("OpenMessage"), 3)
self.assertEqual(kinds.count("ReaderReady"), 3)
self.assertEqual(kinds.count("BackfillProgress"), 1)
self.assertEqual(kinds.count("BackfillSliceStart"), 1)
self.assertEqual(kinds.count("BackfillSliceDone"), 1)
class TestCorrelateOpens(unittest.TestCase):
"""correlate_opens must reproduce the manual timing-tables rows exactly."""
def setUp(self):
events = list(bc.iter_events(_load_fixture_lines()))
self.samples = bc.correlate_opens(events)
def test_three_opens_detected(self):
self.assertEqual(len(self.samples), 3)
def test_cached_gmail_open(self):
s = self.samples[0]
self.assertEqual(s.account_ref, "imap:94058a")
self.assertTrue(s.cached)
self.assertEqual(s.took_ms, 21)
self.assertIsNone(s.body_fetch)
self.assertEqual(s.reader_ready.took_ms, 82)
def test_gmail_A1_row_matches_table1(self):
s = self.samples[1]
self.assertEqual(s.account_ref, "imap:94058a")
self.assertFalse(s.cached)
self.assertEqual(s.took_ms, 31227)
self.assertEqual(s.reader_ready.took_ms, 31617)
self.assertEqual(s.rfc822_bytes, 60457)
self.assertEqual(s.body_fetch.select_ms, 2566)
self.assertEqual(s.body_fetch.body_ms, 14253)
self.assertEqual(s.body_fetch.flag_ms, 2356)
self.assertEqual(s.body_fetch_op.connect_ms, 2624)
self.assertEqual(s.body_fetch_op.work_ms, 26211)
self.assertEqual(s.body_fetch_op.live, 3)
self.assertAlmostEqual(s.body_kb_per_s, 4.24, places=2)
def test_outlook_O1_row_matches_table2(self):
s = self.samples[2]
self.assertEqual(s.account_ref, "outlook:6b54d6")
self.assertFalse(s.cached)
self.assertEqual(s.took_ms, 2934)
self.assertEqual(s.reader_ready.took_ms, 2939)
self.assertEqual(s.rfc822_bytes, 189086)
self.assertEqual(s.body_fetch_op.connect_ms, 772)
self.assertAlmostEqual(s.body_kb_per_s, 139.5, places=1)
if __name__ == "__main__":
unittest.main()
+109
View File
@@ -0,0 +1,109 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Tests for aggregation and the timing-tables markdown renderers."""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import breadcrumbs as bc # noqa: E402
import report # noqa: E402
FIXTURE = os.path.join(os.path.dirname(__file__), "fixtures", "perf-extract-sample.log")
class TestAggregate(unittest.TestCase):
def test_basic_stats(self):
agg = report.aggregate([10, 20, 30])
self.assertEqual(agg.n, 3)
self.assertEqual(agg.mean, 20)
self.assertEqual(agg.median, 20)
self.assertEqual((agg.minimum, agg.maximum), (10, 30))
def test_reproduces_manual_gmail_median(self):
# timing-tables.md Condition A openMessage values -> median ~32.1 s.
agg = report.aggregate([31227, 32059, 31721, 32148, 32949, 39728])
self.assertEqual(agg.median, 32103.5)
def test_empty(self):
agg = report.aggregate([])
self.assertEqual(agg.n, 0)
self.assertIsNone(agg.mean)
self.assertIsNone(agg.median)
class TestMdTable(unittest.TestCase):
def test_table_shape(self):
table = report.md_table(["a", "b"], [[1, 2], [3, None]])
lines = table.splitlines()
self.assertEqual(lines[0], "| a | b |")
self.assertEqual(lines[1], "| --- | --- |")
self.assertEqual(lines[2], "| 1 | 2 |")
self.assertEqual(lines[3], "| 3 | |") # None renders empty
class TestColdOpen(unittest.TestCase):
def test_render_includes_mean_row(self):
samples = [
report.ColdOpenSample(run=1, total_time_ms=523, wait_time_ms=526),
report.ColdOpenSample(run=2, total_time_ms=384, wait_time_ms=386),
]
md = report.render_cold_open(samples)
self.assertIn("523", md)
self.assertIn("**mean**", md)
self.assertIn("454", md) # mean total = 453.5 -> 454
class TestMessageOpen(unittest.TestCase):
def setUp(self):
with open(FIXTURE, encoding="utf-8") as fh:
events = list(bc.iter_events(fh.read().splitlines()))
self.samples = bc.correlate_opens(events)
def test_from_open_sample_maps_fields(self):
row = report.ReaderOpenRow.from_open_sample(self.samples[1], index=1, label="A1")
self.assertEqual(row.took_ms, 31227)
self.assertEqual(row.reader_ready_ms, 31617)
self.assertEqual(row.rfc822_bytes, 60457)
self.assertEqual(row.connect_ms, 2624)
self.assertEqual(row.select_ms, 2566)
self.assertFalse(row.cached)
def test_render_message_open_table_and_aggregate(self):
rows = [
report.ReaderOpenRow.from_open_sample(s, index=i, label=f"m{i}")
for i, s in enumerate(self.samples, start=1)
]
md = report.render_message_open("Message open (uncached)", rows)
self.assertIn("31227 ms", md)
self.assertIn("2934 ms", md)
self.assertIn("60457", md)
self.assertIn("4.2", md) # Gmail throughput
self.assertIn("139.5", md) # Outlook throughput
self.assertIn("n=2", md) # two uncached opens among the three
def test_skipped_row_renders(self):
rows = [report.ReaderOpenRow(index=1, label="x", skipped=True, reason="keyguard")]
md = report.render_message_open("t", rows)
self.assertIn("SKIPPED: keyguard", md)
class TestBackNav(unittest.TestCase):
def test_caveat_present(self):
samples = [report.BackNavSample(index=1, back_ms=3484)]
md = report.render_back_nav(samples)
self.assertIn("Caveat", md)
self.assertIn("3484 ms", md)
class TestDocument(unittest.TestCase):
def test_build_document(self):
doc = report.build_document({"scenario": "cold-open"}, ["## Section\n\nbody\n"])
self.assertIn("# LibreMail device perf run", doc)
self.assertIn("**scenario:** cold-open", doc)
self.assertIn("## Section", doc)
if __name__ == "__main__":
unittest.main()
+108
View File
@@ -0,0 +1,108 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Tests for the uiautomator-dump parser and screen/keyguard recognition.
Fixtures are cleaned copies of the manual run's real dumps: the reader (``ui_reader.xml``),
a deskclock alarm (``ui_alarm.xml``) and the keyguard (``ui_lockscreen.xml``) are the
foreign-screen negatives the harness must skip; ``ui_mailbox.xml`` is a compact, structurally
faithful mailbox list.
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import uidump # noqa: E402
FIX = os.path.join(os.path.dirname(__file__), "fixtures")
APP = "org.libremail.app"
def load(name):
with open(os.path.join(FIX, name), encoding="utf-8") as fh:
return uidump.parse_dump(fh.read())
class TestBounds(unittest.TestCase):
def test_parse_bounds(self):
self.assertEqual(uidump.parse_bounds("[0,392][1344,617]"), (0, 392, 1344, 617))
def test_parse_bounds_none(self):
self.assertIsNone(uidump.parse_bounds(""))
self.assertIsNone(uidump.parse_bounds("not-bounds"))
class TestMailbox(unittest.TestCase):
def setUp(self):
self.root = load("ui_mailbox.xml")
def test_recognised_as_app_not_lockscreen(self):
self.assertEqual(uidump.foreground_package(self.root), APP)
self.assertFalse(uidump.is_lockscreen(self.root))
self.assertTrue(uidump.is_app_foreground(self.root, APP))
self.assertFalse(uidump.is_reader(self.root, APP))
def test_finds_three_message_rows(self):
rows = uidump.find_message_rows(self.root, APP)
self.assertEqual(len(rows), 3)
def test_row_centers_and_cached_flags(self):
rows = uidump.find_message_rows(self.root, APP)
self.assertEqual(rows[0].center, (672, 504))
self.assertFalse(rows[0].cached)
self.assertEqual(rows[1].center, (672, 732))
self.assertFalse(rows[1].cached)
# Row 2 carries the "Available offline" content-desc -> cached.
self.assertEqual(rows[2].center, (672, 988))
self.assertTrue(rows[2].cached)
def test_row_label_drops_monogram(self):
rows = uidump.find_message_rows(self.root, APP)
self.assertIn("github-actions[bot]", rows[0].label)
self.assertNotIn(" G ", rows[0].label) # single-letter avatar filtered out
def test_settings_entry_tappable(self):
node = uidump.find_by_text(self.root, "Settings")
self.assertIsNotNone(node)
anchor = node.first_clickable_ancestor()
self.assertIsNotNone(anchor)
self.assertEqual(anchor.center, (1014, 2728))
def test_top_bar_content_descs(self):
self.assertIsNotNone(uidump.find_by_content_desc(self.root, "Show folders"))
self.assertIsNotNone(uidump.find_by_content_desc(self.root, "Search"))
class TestReader(unittest.TestCase):
def setUp(self):
self.root = load("ui_reader.xml")
def test_recognised_as_reader(self):
self.assertTrue(uidump.is_app_foreground(self.root, APP))
self.assertTrue(uidump.is_reader(self.root, APP))
def test_progress_bar_signals_loading(self):
self.assertTrue(uidump.has_progress_bar(self.root))
def test_no_message_rows_in_reader(self):
self.assertEqual(uidump.find_message_rows(self.root, APP), [])
class TestForeignScreensAreGuarded(unittest.TestCase):
def test_lockscreen_detected(self):
root = load("ui_lockscreen.xml")
self.assertTrue(uidump.is_lockscreen(root))
self.assertFalse(uidump.is_app_foreground(root, APP))
self.assertFalse(uidump.is_reader(root, APP))
def test_alarm_is_foreign_app(self):
root = load("ui_alarm.xml")
self.assertEqual(uidump.foreground_package(root), "com.google.android.deskclock")
self.assertFalse(uidump.is_app_foreground(root, APP))
self.assertFalse(uidump.is_lockscreen(root))
self.assertFalse(uidump.is_reader(root, APP))
if __name__ == "__main__":
unittest.main()
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""uidump.py -- parse ``uiautomator dump`` XML and recognise LibreMail's screens.
Pure (no device dependency) so it can be unit-tested against the saved dumps from the
manual run. The Compose UI exposes no stable ``resource-id``s, so screens and rows are
recognised structurally -- by class, clickable flags, bounds, and descendant
text/content-desc -- exactly as observed in ``ui_mailbox.xml`` / ``ui_reader.xml``.
It also provides the keyguard / foreign-app guards the harness needs: a uiautomator sample
taken against the lockscreen (``ui-mbox.xml`` in the manual run was a keyguard capture) or
another app (``ui-03.xml`` was a deskclock alarm) must be detected and skipped.
"""
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
from collections import Counter
from dataclasses import dataclass, field
from typing import Callable, List, Optional, Tuple
SYSTEMUI_PACKAGE = "com.android.systemui"
_BOUNDS_RE = re.compile(r"\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]")
def parse_bounds(text: str) -> Optional[Tuple[int, int, int, int]]:
"""``"[x1,y1][x2,y2]"`` -> ``(x1, y1, x2, y2)`` (or ``None``)."""
m = _BOUNDS_RE.search(text or "")
if not m:
return None
return tuple(int(g) for g in m.groups()) # type: ignore[return-value]
@dataclass
class UiNode:
"""One uiautomator node with parsed attributes and parent/child links."""
cls: str = ""
text: str = ""
resource_id: str = ""
content_desc: str = ""
package: str = ""
clickable: bool = False
long_clickable: bool = False
scrollable: bool = False
enabled: bool = False
bounds: Optional[Tuple[int, int, int, int]] = None
parent: Optional["UiNode"] = None
children: List["UiNode"] = field(default_factory=list)
@property
def center(self) -> Optional[Tuple[int, int]]:
if not self.bounds:
return None
x1, y1, x2, y2 = self.bounds
return ((x1 + x2) // 2, (y1 + y2) // 2)
@property
def height(self) -> int:
return (self.bounds[3] - self.bounds[1]) if self.bounds else 0
def walk(self):
"""Depth-first iterate over this node and all descendants."""
yield self
for child in self.children:
yield from child.walk()
def find_all(self, predicate: Callable[["UiNode"], bool]) -> List["UiNode"]:
return [n for n in self.walk() if predicate(n)]
def first_clickable_ancestor(self) -> Optional["UiNode"]:
node: Optional[UiNode] = self
while node is not None:
if node.clickable:
return node
node = node.parent
return None
def descendant_texts(self) -> List[str]:
"""Non-empty ``text`` values under this node, in document order."""
return [n.text for n in self.walk() if n.text]
def descendant_descs(self) -> List[str]:
return [n.content_desc for n in self.walk() if n.content_desc]
def _to_bool(value: Optional[str]) -> bool:
return value == "true"
def _build(elem: ET.Element, parent: Optional[UiNode]) -> UiNode:
node = UiNode(
cls=elem.get("class", ""),
text=elem.get("text", ""),
resource_id=elem.get("resource-id", ""),
content_desc=elem.get("content-desc", ""),
package=elem.get("package", ""),
clickable=_to_bool(elem.get("clickable")),
long_clickable=_to_bool(elem.get("long-clickable")),
scrollable=_to_bool(elem.get("scrollable")),
enabled=_to_bool(elem.get("enabled")),
bounds=parse_bounds(elem.get("bounds", "")),
parent=parent,
)
for child_elem in list(elem):
if child_elem.tag == "node":
node.children.append(_build(child_elem, node))
return node
def parse_dump(xml_text: str) -> UiNode:
"""Parse uiautomator XML into a synthetic root :class:`UiNode` (the ``<hierarchy>``)."""
root_elem = ET.fromstring(xml_text.strip())
root = UiNode(cls="hierarchy")
for child_elem in list(root_elem):
if child_elem.tag == "node":
root.children.append(_build(child_elem, root))
return root
# --------------------------------------------------------------------------- #
# Screen recognition
# --------------------------------------------------------------------------- #
def foreground_package(root: UiNode) -> str:
"""The package that owns most of the tree -- a robust "what's on screen" signal."""
counts = Counter(n.package for n in root.walk() if n.package)
if not counts:
return ""
return counts.most_common(1)[0][0]
def is_lockscreen(root: UiNode) -> bool:
"""True if the dump is the keyguard/lockscreen rather than an app."""
if foreground_package(root) != SYSTEMUI_PACKAGE:
return False
for node in root.walk():
if "keyguard" in node.resource_id or node.content_desc in (
"Lock screen",
"Fingerprint sensor",
):
return True
return False
def is_app_foreground(root: UiNode, package: str) -> bool:
"""True if ``package`` is the foreground app in this dump."""
return foreground_package(root) == package
def find_by_text(root: UiNode, text: str, exact: bool = True) -> Optional[UiNode]:
for node in root.walk():
if (node.text == text) if exact else (text in node.text):
return node
return None
def find_by_content_desc(root: UiNode, desc: str, exact: bool = True) -> Optional[UiNode]:
for node in root.walk():
if (node.content_desc == desc) if exact else (desc in node.content_desc):
return node
return None
def is_reader(root: UiNode, package: str) -> bool:
"""The message reader: a Back affordance plus the 'Message' title, in our package."""
if not is_app_foreground(root, package):
return False
has_back = find_by_content_desc(root, "Back") is not None
has_title = find_by_text(root, "Message") is not None
return has_back and has_title
def has_progress_bar(root: UiNode) -> bool:
"""True if a ProgressBar is present (reader still loading its body -- a fallback signal)."""
return any(n.cls.endswith("ProgressBar") for n in root.walk())
@dataclass
class MessageRow:
"""A tappable message row in the mailbox list."""
index: int
bounds: Tuple[int, int, int, int]
center: Tuple[int, int]
texts: List[str]
cached: bool
@property
def label(self) -> str:
"""A short, log-safe identifier -- sender + subject when available."""
if not self.texts:
return f"row#{self.index}"
if len(self.texts) >= 2:
return f"{self.texts[0]} / {self.texts[-1]}"
return self.texts[0]
def find_message_rows(root: UiNode, package: str) -> List[MessageRow]:
"""Return the tappable message rows in the mailbox's scrollable list.
Rows are the ``clickable`` + ``long-clickable`` children of the scrollable list (as in
``ui_mailbox.xml``). A row carrying the ``"Available offline"`` content-desc has its
body cached already, so it is marked ``cached`` (callers pick uncached rows for the
uncached-open scenarios).
"""
scrollables = root.find_all(lambda n: n.scrollable and n.package == package)
rows: List[MessageRow] = []
seen_bounds = set()
for scroller in scrollables:
for child in scroller.children:
if not (child.clickable and child.long_clickable and child.bounds):
continue
if child.bounds in seen_bounds:
continue
seen_bounds.add(child.bounds)
texts = child.descendant_texts()
# Drop single-letter avatar monograms; keep sender/subject/time.
texts = [t for t in texts if len(t) > 1]
cached = any(d == "Available offline" for d in child.descendant_descs())
rows.append(
MessageRow(
index=len(rows),
bounds=child.bounds,
center=child.center, # type: ignore[arg-type]
texts=texts,
cached=cached,
)
)
return rows