Merge pull request #370 from JMR-dev/feat-device-testing-harness

feat(scripts): device-testing perf harness
This commit was merged in pull request #370.
This commit is contained in:
Jason Ross
2026-07-06 15:22:20 -05:00
committed by GitHub
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