Compare commits
279
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d18c99280e | ||
|
|
98d2a7c0db | ||
|
|
4d1f2ae9ca | ||
|
|
f049898be1 | ||
|
|
b1489e35da | ||
|
|
d926320123 | ||
|
|
2df16a8293 | ||
|
|
950aabb470 | ||
|
|
58447d7d12 | ||
|
|
abfa60ba86 | ||
|
|
4c3937edde | ||
|
|
cb9e0e3e54 | ||
|
|
b03e363888 | ||
|
|
8a70b329ab | ||
|
|
c5144ebe03 | ||
|
|
b1a7931dfa | ||
|
|
65eae0f6c7 | ||
|
|
d7429dfef0 | ||
|
|
ca62f77064 | ||
|
|
9407b20914 | ||
|
|
5c564ebeda | ||
|
|
0273ea4d39 | ||
|
|
2cd282fedc | ||
|
|
389bd974d0 | ||
|
|
b98ae99abc | ||
|
|
52da77b6a0 | ||
|
|
2c35301756 | ||
|
|
94fee9779a | ||
|
|
3cd441c85e | ||
|
|
b63354b89a | ||
|
|
9e6f3ebef8 | ||
|
|
2c215c696a | ||
|
|
b9863b16ca | ||
|
|
2b958404bb | ||
|
|
677512760a | ||
|
|
6ce9274ff6 | ||
|
|
7a0cc3145c | ||
|
|
487c3b36a5 | ||
|
|
5c00a8da70 | ||
|
|
91f2f7105b | ||
|
|
08be7e6541 | ||
|
|
b038c3bdae | ||
|
|
d0ed168fcb | ||
|
|
2c0ca30919 | ||
|
|
1ee6366bec | ||
|
|
6802b60c42 | ||
|
|
fc9c87629c | ||
|
|
68e45df0ea | ||
|
|
49594da10e | ||
|
|
a1455d541c | ||
|
|
4d724a52a8 | ||
|
|
f629091b18 | ||
|
|
b5b789f6c7 | ||
|
|
d9e0d6410c | ||
|
|
1d17f76b15 | ||
|
|
d55a1c3fae | ||
|
|
d6665fe8c3 | ||
|
|
9095cf190f | ||
|
|
63c0d5f9f9 | ||
|
|
8f7926886c | ||
|
|
60f822a63c | ||
|
|
8632500cf6 | ||
|
|
51dd278441 | ||
|
|
27851c14be | ||
|
|
08290dc85f | ||
|
|
8fac4c1125 | ||
|
|
410e742b20 | ||
|
|
5531a6a0c0 | ||
|
|
ab5a6b334e | ||
|
|
8cacff6b4a | ||
|
|
5d593f683c | ||
|
|
a75e66ea3d | ||
|
|
a95b6f0b3f | ||
|
|
0c8a9e688f | ||
|
|
6be3b41dd1 | ||
|
|
146d39e2cb | ||
|
|
099474d32a | ||
|
|
a4d1bd6fc4 | ||
|
|
41dca1840d | ||
|
|
90dfb189e6 | ||
|
|
27ede55dbd | ||
|
|
2f32657aff | ||
|
|
5656b3cd99 | ||
|
|
d3264db920 | ||
|
|
404c107aef | ||
|
|
55f1f59e3d | ||
|
|
582d1f3077 | ||
|
|
4464e3f5e4 | ||
|
|
4f09efaf84 | ||
|
|
85009ee88a | ||
|
|
2887516faa | ||
|
|
9f7ebdafb9 | ||
|
|
97a51ed17d | ||
|
|
a1cf982a84 | ||
|
|
5f69082ba3 | ||
|
|
4b6f206f2d | ||
|
|
190343bd84 | ||
|
|
cd25544e07 | ||
|
|
c42f9a7e01 | ||
|
|
b9cadde5bd | ||
|
|
74a2cde7b1 | ||
|
|
15f4af864f | ||
|
|
9259591dc8 | ||
|
|
33a7ca1b7d | ||
|
|
37008d622e | ||
|
|
b4453f6991 | ||
|
|
40b14d51cc | ||
|
|
594c6167d3 | ||
|
|
6a5e86bb11 | ||
|
|
e4db3cadf6 | ||
|
|
0307df88a1 | ||
|
|
41aed01544 | ||
|
|
98b90a19c3 | ||
|
|
7f1fb3ea43 | ||
|
|
b8e3b97377 | ||
|
|
c4607c2df1 | ||
|
|
e668330151 | ||
|
|
d922b9a6ee | ||
|
|
3ea00a1ed9 | ||
|
|
799669d6a6 | ||
|
|
8f8608a430 | ||
|
|
ae10a5ff3b | ||
|
|
c2ab0e3141 | ||
|
|
9bbfa2108a | ||
|
|
cfd433af6f | ||
|
|
8689885964 | ||
|
|
6213ee8901 | ||
|
|
8ac1d28dc8 | ||
|
|
aa628831be | ||
|
|
1053fa80f0 | ||
|
|
35b6b35d16 | ||
|
|
5a8c4e386e | ||
|
|
c04198a158 | ||
|
|
0b1fb05a90 | ||
|
|
e062331e09 | ||
|
|
187a8effb0 | ||
|
|
0186fa5ed9 | ||
|
|
324f7c2c51 | ||
|
|
62797bc8c3 | ||
|
|
c8290ee545 | ||
|
|
9aa83a458c | ||
|
|
cf1a8f6b83 | ||
|
|
bb412e263e | ||
|
|
b02ed87313 | ||
|
|
32199ef037 | ||
|
|
1423395454 | ||
|
|
1a3393da53 | ||
|
|
08ee9e7abc | ||
|
|
5f77eb7d8b | ||
|
|
d6c694c7e0 | ||
|
|
ca6d90b602 | ||
|
|
0e9f54e686 | ||
|
|
a84b042af5 | ||
|
|
66da643249 | ||
|
|
042b50116c | ||
|
|
b0ca5421b6 | ||
|
|
bfe5d46654 | ||
|
|
551a2df66f | ||
|
|
e436503eaf | ||
|
|
5c715e1676 | ||
|
|
b8d67557a0 | ||
|
|
e36afc8ade | ||
|
|
81a3b7ea34 | ||
|
|
060b7b1a71 | ||
|
|
cc067408b8 | ||
|
|
a069188b65 | ||
|
|
921681812c | ||
|
|
52503c000c | ||
|
|
6cb179bd8b | ||
|
|
35b869944e | ||
|
|
f93e2dc2f0 | ||
|
|
939906986b | ||
|
|
6118b6ded0 | ||
|
|
1cd0d66fa4 | ||
|
|
c3933a3612 | ||
|
|
2fcee291ce | ||
|
|
33217c6cb2 | ||
|
|
d8f6a66856 | ||
|
|
46bc0e2258 | ||
|
|
44d6d1edb6 | ||
|
|
6f4275ee1e | ||
|
|
05d06eb45b | ||
|
|
4bf6fa5535 | ||
|
|
833dfc030a | ||
|
|
11e0a85475 | ||
|
|
d795639a32 | ||
|
|
94a7562256 | ||
|
|
7d60c7ac60 | ||
|
|
88b8f6cb07 | ||
|
|
dd9f6bb8d9 | ||
|
|
348d7adc28 | ||
|
|
45df6c7709 | ||
|
|
cefd92864c | ||
|
|
b3ec24d9f3 | ||
|
|
5d50b60f68 | ||
|
|
5047e3eb2a | ||
|
|
494649b7d8 | ||
|
|
faab0e3260 | ||
|
|
a9f0c220da | ||
|
|
a44f568f9c | ||
|
|
c65fb26ad0 | ||
|
|
17d40119ca | ||
|
|
d0ec1949a2 | ||
|
|
b2c017b52d | ||
|
|
02153b5287 | ||
|
|
ef16b1ef2c | ||
|
|
76caaddd33 | ||
|
|
8b89219734 | ||
|
|
dc21411aed | ||
|
|
a13bc9eb07 | ||
|
|
37bdaae304 | ||
|
|
527e31fb5c | ||
|
|
28745827c2 | ||
|
|
647490c1a8 | ||
|
|
785c6aa6c6 | ||
|
|
a34db54b97 | ||
|
|
14eebcb6c1 | ||
|
|
cbf159160f | ||
|
|
d51778379a | ||
|
|
30026c1b29 | ||
|
|
6055fdc44d | ||
|
|
ab41d8f24b | ||
|
|
c0dc5c5e75 | ||
|
|
8b7f895d6a | ||
|
|
a24435a4fd | ||
|
|
fc38255be5 | ||
|
|
6f20a23ae9 | ||
|
|
7e0b5c2b1c | ||
|
|
793346ab46 | ||
|
|
fff8afb453 | ||
|
|
b912af0ee2 | ||
|
|
03179cda15 | ||
|
|
8cc792c06e | ||
|
|
59f86016cb | ||
|
|
a891be4b14 | ||
|
|
ca4c310a66 | ||
|
|
3d4dde6bc5 | ||
|
|
ff49c6c410 | ||
|
|
209371c9a5 | ||
|
|
59b4252ce8 | ||
|
|
d6e886bde0 | ||
|
|
0236494ecb | ||
|
|
6e0fe14b06 | ||
|
|
c149411a9d | ||
|
|
21c74df794 | ||
|
|
f642f2c2b5 | ||
|
|
73c636704f | ||
|
|
eee1475a61 | ||
|
|
a2f96b930d | ||
|
|
0cb9bb2906 | ||
|
|
9e771a9590 | ||
|
|
d92d4c1a14 | ||
|
|
6893649f0d | ||
|
|
41015a5c40 | ||
|
|
9ea339436d | ||
|
|
1a1fbf8d7f | ||
|
|
0d9761dda2 | ||
|
|
b352e3838c | ||
|
|
f54e9c67fa | ||
|
|
be2afd42c4 | ||
|
|
47aaebccf5 | ||
|
|
2e0eaef4e9 | ||
|
|
cc1f4442b6 | ||
|
|
e4457cfec1 | ||
|
|
2e04934e19 | ||
|
|
8cb57cba50 | ||
|
|
2289f402e5 | ||
|
|
97b782ecb2 | ||
|
|
b33f73273d | ||
|
|
1d4bd6346c | ||
|
|
df57dcd18d | ||
|
|
e80f8333f4 | ||
|
|
da2963aa86 | ||
|
|
8230dd0341 | ||
|
|
5c34b01bf0 | ||
|
|
f4a95a6051 | ||
|
|
991f9b77e4 | ||
|
|
bdb49f996f | ||
|
|
5a3669f017 |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: preflight
|
||||
description: Run LibreMail's fast CI gate locally (assembleDebug + testDebugUnitTest + compileDebugAndroidTestKotlin + lintDebug + ktlintCheck + detekt) plus the top-of-matrix emulator E2E (currently API 35 + 36 via Gradle Managed Devices, then API 37 preview via the hand-provisioning script) before pushing or opening a PR. Mirrors the merge gate; the rest of the multi-API matrix stays CI-only. Use before treating a change as done.
|
||||
description: Run LibreMail's fast CI gate locally (assembleDebug + testDebugUnitTest + jacocoTestCoverageVerification + compileDebugAndroidTestKotlin + lintDebug + ktlintCheck + detekt) plus the local emulator E2E — the instrumented test class(es) you changed via local_instrumented.py (cold-boot, no Gradle Managed Devices), then the API 37 preview via api37_e2e.py — before pushing or opening a PR. Mirrors the merge gate; CI runs the full multi-API matrix. Use before treating a change as done.
|
||||
---
|
||||
|
||||
# /preflight
|
||||
@@ -13,20 +13,24 @@ Run the same fast checks CI enforces on every PR, in order, and report the outco
|
||||
with a JDK/AGP version mismatch, check `java -version` / `JAVA_HOME` and point it at a 17–21
|
||||
JDK (e.g. Android Studio's bundled JBR) before retrying.
|
||||
- PowerShell: invoke the wrapper as `.\gradlew`. Git Bash / the Bash tool: `./gradlew`.
|
||||
- The final three E2E steps each boot an emulator, so the host needs a **free hardware
|
||||
- The final two E2E steps each boot an emulator, so the host needs a **free hardware
|
||||
hypervisor** (Intel VT-x / AMD-V, exposed as WHPX on Windows, KVM on Linux, HVF on macOS).
|
||||
Shut down VirtualBox, Hyper-V-based VMs, WSL2, Docker Desktop, or any other emulator first — a
|
||||
VM holding the hypervisor starves the AVD, and it hangs at 0% CPU and never reaches
|
||||
`sys.boot_completed`. The api35/api36 steps run through Gradle Managed Devices (Gradle
|
||||
downloads the image and boots/tears down each AVD itself); the api37 step is hand-provisioned
|
||||
by `api37_e2e.py` (see Steps). The first run per API level is slow while its system image
|
||||
`sys.boot_completed`. Both steps are hand-provisioned by cross-platform Python scripts (**not**
|
||||
Gradle Managed Devices, which fail locally on this box — see Steps): `local_instrumented.py`
|
||||
cold-boots one existing AVD and runs the instrumented class(es) you changed; `api37_e2e.py`
|
||||
installs + boots the API 37 preview image. The first API 37 run is slow while its system image
|
||||
downloads. If the host has no accelerated emulator and a device cannot boot, report the E2E
|
||||
step as not run rather than treating the gate as green.
|
||||
- The api37 step is a stdlib-only, cross-platform **Python 3** script and needs `python3` plus
|
||||
the Android SDK command-line tools (`sdkmanager`/`avdmanager`) and `emulator` on the machine,
|
||||
located via `ANDROID_SDK_ROOT`/`ANDROID_HOME` (or the per-OS default:
|
||||
`%LOCALAPPDATA%\Android\Sdk` on Windows, `~/Library/Android/sdk` on macOS, `~/Android/Sdk` on
|
||||
Linux). The script installs the preview system image itself on first run.
|
||||
- Both E2E steps are stdlib-only, cross-platform **Python 3** scripts. `local_instrumented.py`
|
||||
needs `python3` plus the Android SDK `emulator` + `adb` on `PATH` and an existing AVD (any local
|
||||
`apiXXDebugAndroidTest` run creates one; override with `LOCAL_INSTRUMENTED_AVD` /
|
||||
`ANDROID_AVD_HOME`), and it pins `JAVA_HOME` to a JDK 17–21 itself (override with
|
||||
`LOCAL_INSTRUMENTED_JDK`). `api37_e2e.py` additionally needs the Android SDK command-line tools
|
||||
(`sdkmanager`/`avdmanager`), located via `ANDROID_SDK_ROOT`/`ANDROID_HOME` (or the per-OS
|
||||
default: `%LOCALAPPDATA%\Android\Sdk` on Windows, `~/Library/Android/sdk` on macOS,
|
||||
`~/Android/Sdk` on Linux); it installs the preview system image itself on first run.
|
||||
|
||||
## Steps
|
||||
|
||||
@@ -34,15 +38,19 @@ Run these, stopping at the first failure:
|
||||
|
||||
```bash
|
||||
./gradlew :app:assembleDebug
|
||||
./gradlew :app:testDebugUnitTest
|
||||
./gradlew :app:testDebugUnitTest :app:jacocoTestCoverageVerification
|
||||
./gradlew :app:compileDebugAndroidTestKotlin
|
||||
./gradlew :app:lintDebug
|
||||
./gradlew :app:ktlintCheck :app:detekt
|
||||
./gradlew :app:api35DebugAndroidTest # top-of-matrix emulator E2E (2nd-highest stable level)
|
||||
./gradlew :app:api36DebugAndroidTest # top-of-matrix emulator E2E (highest stable level)
|
||||
python3 .claude/skills/preflight/local_instrumented.py <your.Changed.TestClass>[,<Class2>,...] # local instrumented/E2E, cold-boot (no GMD)
|
||||
python3 .claude/skills/preflight/api37_e2e.py # api37 preview E2E (hand-provisioned; on Windows: py or python)
|
||||
```
|
||||
|
||||
`jacocoTestCoverageVerification` runs right after `testDebugUnitTest` because it reads that
|
||||
task's JVM exec data — it enforces the whole-app **no-regression line-coverage floor (currently
|
||||
0.84)**, so a coverage regression is caught locally instead of only in CI (the exact class of
|
||||
failure that reached CI on #367).
|
||||
|
||||
`compileDebugAndroidTestKotlin` compiles the `androidTest` source set — the E2E/instrumented
|
||||
tests — without needing an emulator. `assembleDebug`, `testDebugUnitTest`, and `lintDebug` never
|
||||
compile that source set, so a change that breaks it (e.g. an instrumented test calling a UI API
|
||||
@@ -54,12 +62,19 @@ merge gate even when the build and lint are green. Add `--continue` to any comma
|
||||
`:app:ktlintCheck :app:detekt --continue`) to collect every failure in one pass instead of
|
||||
stopping at the first.
|
||||
|
||||
The three E2E steps run last because they are the slowest. `api35DebugAndroidTest` and
|
||||
`api36DebugAndroidTest` run the full instrumented/E2E suite on `api35`, then `api36` — the top
|
||||
two stable levels in the E2E matrix — each via its own Gradle Managed Device, which Gradle
|
||||
provisions, boots, and tears down automatically.
|
||||
The two E2E steps run last because they are the slowest, and they run through **cross-platform
|
||||
Python scripts, not Gradle Managed Devices (GMD)**. GMD's `apiXXDebugAndroidTest` tasks fail
|
||||
locally on this box — GMD's AVD-snapshot step times out under the AEHD 2.2 hypervisor
|
||||
(`AvdSnapshotHandler$EmulatorSnapshotCannotCreatedException`), cycling for hours — so preflight
|
||||
does **not** call them (issue #269/#281). `local_instrumented.py` instead cold-boots one existing
|
||||
AVD by hand with `-no-snapshot` (the exact `connectedDebugAndroidTest` technique CI and
|
||||
`api37_e2e.py` use), runs `:app:connectedDebugAndroidTest` filtered to the instrumented class(es)
|
||||
you pass, then tears the emulator down and verifies no orphaned `qemu` process is left behind
|
||||
(exit 3 if one survives). Pass the instrumented/E2E class(es) you actually changed
|
||||
(comma-separated, no spaces) — the full ~114-test suite tends to wedge mid-run locally, so
|
||||
targeted runs are deliberate; the whole suite across every API level is CI's job.
|
||||
|
||||
`api37_e2e.py` then runs the same suite on the **API 37 preview** emulator. API 37 has no Gradle
|
||||
`api37_e2e.py` then runs the instrumented/E2E suite on the **API 37 preview** emulator. API 37 has no Gradle
|
||||
Managed Device — its only published system image is the nonstandard `android-37.0` /
|
||||
`google_apis_ps16k` pairing, which neither `ManagedVirtualDevice`'s `apiLevel` (Int) nor
|
||||
`apiPreview` (codename) DSL resolves (see the comment above `testOptions.managedDevices` in
|
||||
@@ -72,26 +87,27 @@ match `e2e-preview` with one deliberate local exception: the **GPU mode**. CI us
|
||||
local run uses `-gpu auto-no-window`, which renders on the host GPU — faster, and the mode that
|
||||
boots cleanly on a dev machine.
|
||||
|
||||
All three levels are the E2E that preflight runs locally and all three must pass; CI fans the
|
||||
same suite out across the whole matrix (API 29–36 in `e2e`, plus API 37 in `e2e-preview`). Keep
|
||||
`api35DebugAndroidTest` / `api36DebugAndroidTest` in lockstep with the top of the managed-device
|
||||
list in `app/build.gradle.kts`, and keep `api37_e2e.py` in lockstep with the `e2e-preview` job in
|
||||
`.github/workflows/ci.yml` (same image string + emulator flags, apart from the intentional GPU-mode
|
||||
difference noted above) — when a newer API level is added there, run the new top levels instead.
|
||||
Both E2E steps are the E2E that preflight runs locally and both must pass; CI then fans the full
|
||||
instrumented/E2E suite out across the whole matrix (API 29–36 in `e2e`, plus API 37 in
|
||||
`e2e-preview`). Keep `local_instrumented.py` pointed at the instrumented class(es) you changed,
|
||||
and keep `api37_e2e.py` in lockstep with the `e2e-preview` job in `.github/workflows/ci.yml`
|
||||
(same image string + emulator flags, apart from the intentional GPU-mode difference noted above).
|
||||
The local gate no longer runs the GMD `apiXXDebugAndroidTest` tasks (they are unusable locally —
|
||||
see above); full multi-API coverage stays CI's job.
|
||||
|
||||
## Reporting
|
||||
|
||||
- If everything passes, say so plainly (e.g. "preflight green: build, unit tests, lint, ktlint, detekt, api35+api36+api37 E2E").
|
||||
- If everything passes, say so plainly (e.g. "preflight green: build, unit tests, lint, ktlint, detekt, local + api37 E2E").
|
||||
- On failure, surface the actual Gradle error and point at the relevant report:
|
||||
- unit tests → `app/build/reports/tests/testDebugUnitTest/`
|
||||
- lint → `app/build/reports/lint-results-debug.html`
|
||||
- ktlint → `app/build/reports/ktlint/` (per source set, e.g. `ktlintTestSourceSetCheck/`)
|
||||
- detekt → `app/build/reports/detekt/`
|
||||
- api35/api36 E2E → `app/build/reports/androidTests/managedDevice/` (per-device HTML, e.g.
|
||||
`.../api35/`, `.../api36/`)
|
||||
- api37 E2E → `app/build/reports/androidTests/connected/` (the `connectedDebugAndroidTest`
|
||||
report the script drives); the emulator's own boot log is at the temp path the script prints.
|
||||
- Run the **top two stable-API** levels (`api35DebugAndroidTest` + `api36DebugAndroidTest`) plus
|
||||
the **API 37 preview** via `api37_e2e.py`; the rest of the multi-API matrix (API 29–34) stays
|
||||
CI's job. If the host has no accelerated emulator and a device cannot boot (see the hypervisor
|
||||
note in Preconditions), report that E2E could not run rather than treating the gate as green.
|
||||
- local + api37 E2E → `app/build/reports/androidTests/connected/` (the `connectedDebugAndroidTest`
|
||||
report both scripts drive — the later run overwrites the earlier); each emulator's own boot log
|
||||
is at the temp path the script prints (`local_instrumented.py` also exits **3** if it leaves an
|
||||
orphaned `qemu`, **4** if the emulator never booted).
|
||||
- Run the instrumented class(es) you changed via `local_instrumented.py`, plus the **API 37
|
||||
preview** via `api37_e2e.py`; the full multi-API matrix (API 29–37) stays CI's job. If the host
|
||||
has no accelerated emulator and a device cannot boot (see the hypervisor note in Preconditions),
|
||||
report that E2E could not run rather than treating the gate as green.
|
||||
|
||||
@@ -38,6 +38,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -52,6 +53,11 @@ BUILD_TOOLS = "build-tools;37.0.0"
|
||||
AVD_NAME = "api37"
|
||||
DEVICE_PROFILE = "pixel_2"
|
||||
BOOT_TIMEOUT = 300
|
||||
# GPU mode: the ONE deliberate divergence from CI's e2e-preview (which uses `swiftshader_indirect`
|
||||
# for headless determinism). Locally we render on the host GPU -- faster, and the mode that boots
|
||||
# cleanly on a dev machine. See start_emulator. Kept as a constant so start_emulator and the
|
||||
# boot-failure diagnostics dump report the same value.
|
||||
GPU_MODE = "auto-no-window"
|
||||
|
||||
IS_WINDOWS = os.name == "nt"
|
||||
BAT = ".bat" if IS_WINDOWS else ""
|
||||
@@ -120,15 +126,17 @@ def create_avd(avdmanager: str, emulator: str) -> None:
|
||||
def start_emulator(emulator: str, emu_log: Path, attempt: int) -> subprocess.Popen:
|
||||
print(f"Starting API 37 emulator (attempt {attempt})...")
|
||||
# Flags mirror .github/workflows/ci.yml e2e-preview (cold headless boot, hardware accel
|
||||
# required, no cameras), with ONE deliberate LOCAL exception -- the GPU mode. CI uses
|
||||
# `-gpu swiftshader_indirect` (software rendering, deterministic on a headless CI runner);
|
||||
# locally we use `-gpu auto-no-window`, which renders on the host GPU: faster, and the mode
|
||||
# that boots cleanly on a dev machine. Keep everything except the GPU mode in lockstep with
|
||||
# that job.
|
||||
# required, no cameras), with ONE deliberate LOCAL exception -- the GPU mode (GPU_MODE above:
|
||||
# CI uses `-gpu swiftshader_indirect`, deterministic on a headless CI runner; locally we render
|
||||
# on the host GPU -- faster, and the mode that boots cleanly on a dev machine). `-verbose -debug
|
||||
# init,avd_config,kernel` turns emulator boot logging on by default (mirrors CI) so a boot flake
|
||||
# is diagnosable from $EMU_LOG; it is DIAGNOSTICS ONLY and does not change any boot-affecting
|
||||
# flag. Keep everything except the GPU mode in lockstep with that job.
|
||||
flags = [
|
||||
"-avd", AVD_NAME,
|
||||
"-no-window", "-no-audio", "-no-boot-anim", "-no-snapshot", "-accel", "on",
|
||||
"-gpu", "auto-no-window", "-camera-back", "none", "-camera-front", "none",
|
||||
"-gpu", GPU_MODE, "-camera-back", "none", "-camera-front", "none",
|
||||
"-verbose", "-debug", "init,avd_config,kernel",
|
||||
]
|
||||
log = open(emu_log, "wb") # noqa: SIM115 - handed to the child; closed in the parent below
|
||||
try:
|
||||
@@ -189,6 +197,102 @@ def tail(path: Path, lines: int = 80) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _accel_check(emulator: str) -> str:
|
||||
"""`emulator -accel-check` output -- the accelerator status (WHPX / KVM / HVF availability)."""
|
||||
try:
|
||||
out = subprocess.run(cmd(emulator, "-accel-check"), capture_output=True, text=True,
|
||||
check=False)
|
||||
return (out.stdout + out.stderr).strip() or f"(no output; exit {out.returncode})"
|
||||
except OSError as exc:
|
||||
return f"(accel-check failed: {exc})"
|
||||
|
||||
|
||||
def _kvm_status() -> str:
|
||||
"""/dev/kvm presence (Linux). Off-Linux the accelerator is WHPX/HVF -- see -accel-check."""
|
||||
if os.path.exists("/dev/kvm"):
|
||||
return "/dev/kvm present"
|
||||
return f"/dev/kvm absent (expected off-Linux; platform={platform.system()})"
|
||||
|
||||
|
||||
def _mem_info() -> str:
|
||||
"""Free/total memory. Reads /proc/meminfo on Linux (where CI runs); best-effort elsewhere."""
|
||||
try:
|
||||
meminfo = Path("/proc/meminfo")
|
||||
if meminfo.exists():
|
||||
wanted = {"MemTotal", "MemFree", "MemAvailable"}
|
||||
lines = [line.strip() for line in meminfo.read_text().splitlines()
|
||||
if line.split(":", 1)[0] in wanted]
|
||||
if lines:
|
||||
return "; ".join(lines)
|
||||
except OSError:
|
||||
pass
|
||||
return f"(memory stats unavailable on {platform.system()})"
|
||||
|
||||
|
||||
def _disk_info(path: Path) -> str:
|
||||
"""Free/total disk for the filesystem holding `path` (cross-platform via shutil.disk_usage)."""
|
||||
try:
|
||||
usage = shutil.disk_usage(path)
|
||||
gib = 1024 ** 3
|
||||
return f"total={usage.total / gib:.1f}GiB free={usage.free / gib:.1f}GiB ({path})"
|
||||
except OSError as exc:
|
||||
return f"(disk stats unavailable: {exc})"
|
||||
|
||||
|
||||
def start_logcat(adb: str, logcat_log: Path, attempt: int) -> subprocess.Popen | None:
|
||||
"""Background `adb wait-for-device logcat -v time` to a file. wait-for-device blocks until the
|
||||
device registers, so streaming starts the moment the emulator appears and captures the whole
|
||||
boot. Mirrors CI's e2e-preview logcat capture; appended (with a header) per boot attempt."""
|
||||
try:
|
||||
with open(logcat_log, "a") as marker:
|
||||
marker.write(f"===== logcat (attempt {attempt}) =====\n")
|
||||
log = open(logcat_log, "ab") # noqa: SIM115 - child inherits fd; parent copy closed below
|
||||
try:
|
||||
return subprocess.Popen(cmd(adb, "wait-for-device", "logcat", "-v", "time"),
|
||||
stdout=log, stderr=subprocess.STDOUT)
|
||||
finally:
|
||||
log.close() # the child has inherited its own fd; the parent's copy is no longer needed
|
||||
except OSError as exc:
|
||||
print(f"WARNING: could not start logcat capture: {exc}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def stop_logcat(proc: subprocess.Popen | None) -> None:
|
||||
if proc and proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
def dump_diagnostics(adb: str, emulator: str, emu_log: Path, avd_home: Path, attempt: int) -> None:
|
||||
"""Print boot diagnostics + a concise failure summary to the console -- the local mirror of CI's
|
||||
e2e-preview boot-timeout dump (accel/KVM/GPU/mem/disk/AVD config + emulator.log tail). Local
|
||||
runs PRINT these; CI uploads the same set as an artifact and prints only the concise summary."""
|
||||
accel = _accel_check(emulator)
|
||||
kvm = _kvm_status()
|
||||
config_ini = avd_home / f"{AVD_NAME}.avd" / "config.ini"
|
||||
print(f"===== API 37 boot diagnostics (attempt {attempt}) =====")
|
||||
print("--- adb devices ---")
|
||||
subprocess.run(cmd(adb, "devices"), check=False)
|
||||
print(f"--- emulator -accel-check ---\n{accel}")
|
||||
print(f"--- KVM/hypervisor ---\n{kvm}")
|
||||
print(f"--- GPU mode ---\n{GPU_MODE}")
|
||||
print(f"--- free memory ---\n{_mem_info()}")
|
||||
print(f"--- free disk ---\n{_disk_info(Path(tempfile.gettempdir()))}")
|
||||
print("--- AVD config.ini ---")
|
||||
try:
|
||||
print(config_ini.read_text(errors="replace"))
|
||||
except OSError as exc:
|
||||
print(f"(could not read {config_ini}: {exc})")
|
||||
# Concise failure summary (mirrors CI): accel/KVM status + the last 50 lines of emulator.log.
|
||||
print(f"----- BOOT FAILURE SUMMARY (attempt {attempt}) -----")
|
||||
print(f"accel-check: {accel}")
|
||||
print(f"kvm: {kvm}")
|
||||
tail(emu_log, 50)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Hand-provision + run the API 37 preview E2E suite.")
|
||||
@@ -216,8 +320,14 @@ def main() -> int:
|
||||
os.environ["ANDROID_AVD_HOME"] = str(avd_home)
|
||||
|
||||
emu_log = Path(tempfile.gettempdir()) / "libremail-api37-emulator.log"
|
||||
logcat_log = Path(tempfile.gettempdir()) / "libremail-api37-logcat.txt"
|
||||
try:
|
||||
logcat_log.unlink() # start fresh; start_logcat appends (with a header) per attempt
|
||||
except OSError:
|
||||
pass
|
||||
adb: str | None = None
|
||||
proc: subprocess.Popen | None = None
|
||||
logcat_proc: subprocess.Popen | None = None
|
||||
test_exit = 1
|
||||
|
||||
try:
|
||||
@@ -234,26 +344,39 @@ def main() -> int:
|
||||
# 2. Create the AVD, mirroring CI.
|
||||
create_avd(avdmanager, emulator)
|
||||
|
||||
# 3. Cold-boot headless, retrying once (mirrors CI's two-attempt boot loop).
|
||||
# 3. Cold-boot headless, retrying once (mirrors CI's two-attempt boot loop). Diagnostics
|
||||
# (logcat capture + a boot-timeout dump) are ADDITIVE -- the retry/boot-wait is unchanged.
|
||||
booted = False
|
||||
for attempt in (1, 2):
|
||||
proc = start_emulator(emulator, emu_log, attempt)
|
||||
# Capture logcat from device registration onward (mirrors CI); killed on failure.
|
||||
logcat_proc = start_logcat(adb, logcat_log, attempt)
|
||||
if wait_for_boot(adb, proc, args.boot_timeout):
|
||||
booted = True
|
||||
break
|
||||
print(f"API 37 emulator did not boot within {args.boot_timeout}s (attempt {attempt}).",
|
||||
file=sys.stderr)
|
||||
tail(emu_log)
|
||||
dump_diagnostics(adb, emulator, emu_log, avd_home, attempt)
|
||||
stop_logcat(logcat_proc)
|
||||
logcat_proc = None
|
||||
stop_emulator(adb, proc)
|
||||
proc = None
|
||||
time.sleep(5)
|
||||
if not booted:
|
||||
raise RuntimeError("API 37 preview emulator failed to boot after 2 attempts.")
|
||||
|
||||
# 4. Dismiss the keyguard, then run the instrumented/E2E suite against the booted emulator.
|
||||
subprocess.run(cmd(adb, "shell", "input", "keyevent", "82"), check=False)
|
||||
|
||||
# 4. Force the emulator to grant the app window focus, then GATE on it (the SAME shared
|
||||
# helper CI's e2e / e2e-preview jobs invoke, issue #468), before running the suite: wake
|
||||
# the display, dismiss + disable the keyguard, keep the screen on, disable animations, and
|
||||
# wait for a focused window. Replaces the lone `input keyevent 82`. Best-effort: fall back
|
||||
# to that legacy nudge if the shared helper is somehow missing.
|
||||
repo_root = Path(__file__).resolve().parents[3]
|
||||
focus_gate = repo_root / ".github" / "scripts" / "emulator_focus_gate.py"
|
||||
if focus_gate.is_file():
|
||||
subprocess.run([sys.executable, str(focus_gate), "--adb", adb], check=False)
|
||||
else:
|
||||
subprocess.run(cmd(adb, "shell", "input", "keyevent", "82"), check=False)
|
||||
|
||||
gradlew = repo_root / ("gradlew.bat" if IS_WINDOWS else "gradlew")
|
||||
print(f"Running :app:connectedDebugAndroidTest against {AVD_NAME}...")
|
||||
test_exit = subprocess.run(
|
||||
@@ -266,9 +389,12 @@ def main() -> int:
|
||||
finally:
|
||||
# 5. Always tear the emulator down and delete the AVD, even on failure.
|
||||
print("Tearing down API 37 emulator and AVD...")
|
||||
stop_logcat(logcat_proc)
|
||||
stop_emulator(adb, proc)
|
||||
subprocess.run(cmd(avdmanager, "delete", "avd", "-n", AVD_NAME), check=False,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
print(f"(emulator boot log: {emu_log})")
|
||||
print(f"(logcat: {logcat_log})")
|
||||
|
||||
if test_exit != 0:
|
||||
print(f"api37 connectedDebugAndroidTest failed (exit {test_exit}).", file=sys.stderr)
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
|
||||
# `local_instrumented.sh` — reliable local instrumented/E2E runs
|
||||
# `local_instrumented.py` — reliable local instrumented/E2E runs
|
||||
|
||||
A helper for running LibreMail's instrumented / E2E tests **locally** without Gradle
|
||||
Managed Devices (GMD). Companion to `api37_e2e.py`; born from issue #269.
|
||||
Managed Devices (GMD). Cross-platform, pure Python 3 standard library (Windows / Linux /
|
||||
macOS). Companion to `api37_e2e.py`; born from issue #269, ported from bash to Python in
|
||||
issue #281 so it runs the same on the Windows primary dev box and on \*nix — no Git Bash,
|
||||
no `jq`, no `taskkill`-vs-`kill` gaps.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# in Git Bash, from anywhere — invoke the script by path:
|
||||
.claude/skills/preflight/local_instrumented.sh org.libremail.ui.compose.ComposeScreenE2ETest
|
||||
# from anywhere — invoke the script by path (Windows: use `py` or `python`):
|
||||
python .claude/skills/preflight/local_instrumented.py org.libremail.ui.compose.ComposeScreenE2ETest
|
||||
# multiple classes (comma-separated, no spaces):
|
||||
.claude/skills/preflight/local_instrumented.sh org.libremail.a.FooTest,org.libremail.b.BarTest
|
||||
python .claude/skills/preflight/local_instrumented.py org.libremail.a.FooTest,org.libremail.b.BarTest
|
||||
```
|
||||
|
||||
The script is CWD-independent: it resolves its own repo/worktree root from its script
|
||||
location (three directories up from `.claude/skills/preflight`) and `cd`s there before
|
||||
invoking gradlew, so it always builds *that* tree's `:app` — never whatever tree your
|
||||
shell happens to be sitting in. This matters most when you have several worktrees
|
||||
checked out side by side; run the copy of this script that lives inside the worktree you
|
||||
want to test, regardless of your current directory (issue #284).
|
||||
location (three directories up from `.claude/skills/preflight`) and runs gradlew there, so
|
||||
it always builds *that* tree's `:app` — never whatever tree your shell happens to be
|
||||
sitting in. This matters most when you have several worktrees checked out side by side; run
|
||||
the copy of this script that lives inside the worktree you want to test, regardless of your
|
||||
current directory (issue #284).
|
||||
|
||||
It cold-boots **one** emulator (`-no-snapshot`, no GMD), waits for `sys.boot_completed`,
|
||||
runs `:app:connectedDebugAndroidTest` filtered to the class(es) you pass, then tears the
|
||||
@@ -35,16 +38,25 @@ emulator down and verifies no orphaned `qemu` process is left behind (exit **3**
|
||||
- **Keep runs targeted.** The full ~114-test suite tends to wedge mid-run on this machine;
|
||||
small, targeted class sets do not. That's why the script requires an explicit class list —
|
||||
run only what you changed. The full matrix is CI's job.
|
||||
- **Emulator hygiene is mandatory.** A hung `adb emu kill` leaves a detached
|
||||
`qemu-system-x86_64-headless.exe`; accumulated orphans have frozen this machine. The
|
||||
script force-kills stragglers before booting and after tearing down, and fails loudly if
|
||||
a zombie survives.
|
||||
- **Emulator hygiene is mandatory.** A hung `adb emu kill` leaves a detached qemu VM
|
||||
(`qemu-system-x86_64-headless.exe` on Windows, a `qemu-system-*` process on \*nix);
|
||||
accumulated orphans have frozen this machine. The script force-kills stragglers before
|
||||
booting and after tearing down, and fails loudly (exit 3) if a zombie survives. The
|
||||
orphan-kill is abstracted per-OS (`taskkill /F /IM …` on Windows, `pkill -f qemu-system`
|
||||
on \*nix), and teardown always runs — even on Ctrl-C / error / SIGTERM (try/finally +
|
||||
atexit + SIGINT/SIGTERM handlers).
|
||||
|
||||
See the header comment of `local_instrumented.sh` for the full rationale, requirements, and
|
||||
the `LOCAL_INSTRUMENTED_*` environment overrides (AVD name, JDK home, boot timeout, …).
|
||||
Exit codes: **0** pass · **2** usage/precondition failure · **3** a qemu zombie survived
|
||||
teardown · **4** emulator never booted · any other non-zero = `connectedDebugAndroidTest`'s
|
||||
own test-failure exit code.
|
||||
|
||||
See the module docstring at the top of `local_instrumented.py` for the full rationale,
|
||||
requirements, and the `LOCAL_INSTRUMENTED_*` environment overrides (AVD name, JDK home,
|
||||
boot timeout, …).
|
||||
|
||||
## Requirements
|
||||
|
||||
Git Bash; Android SDK `emulator` + `adb` on `PATH`; a JDK **17–21** (AGP 9.2 fails on 25+ —
|
||||
the script pins `JAVA_HOME` to a known JDK 21, overridable via `LOCAL_INSTRUMENTED_JDK`); and
|
||||
a free hardware hypervisor (shut down VirtualBox / other VMs first).
|
||||
`python3` (Windows: `py`/`python`); Android SDK `emulator` + `adb` on `PATH`; a JDK
|
||||
**17–21** (AGP 9.2 fails on 25+ — the script pins `JAVA_HOME` to a known JDK 21, overridable
|
||||
via `LOCAL_INSTRUMENTED_JDK`); and a free hardware hypervisor (shut down VirtualBox / other
|
||||
VMs first).
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""local_instrumented.py -- reliable LOCAL instrumented / E2E test runner for LibreMail.
|
||||
|
||||
Usage: local_instrumented.py <fully.qualified.TestClass>[,<Class2>,...]
|
||||
Example:
|
||||
python .claude/skills/preflight/local_instrumented.py \
|
||||
org.libremail.ui.compose.ComposeScreenE2ETest
|
||||
python .claude/skills/preflight/local_instrumented.py \
|
||||
org.libremail.ui.compose.ComposeScreenE2ETest,org.libremail.ui.compose.RecipientChipTest
|
||||
|
||||
Cross-platform (Windows / Linux / macOS), pure standard library. Companion to
|
||||
``api37_e2e.py``; ported from the original ``local_instrumented.sh`` (issue #281) so the
|
||||
helper runs the same on the Windows primary dev box and on *nix -- no Git Bash, no ``jq``,
|
||||
no ``taskkill`` vs ``kill`` portability gaps.
|
||||
|
||||
WHY THIS SCRIPT EXISTS (issue #269)
|
||||
------------------------------------
|
||||
On this machine (Windows + the AEHD 2.2 hypervisor) the Gradle Managed Device (GMD)
|
||||
instrumented tasks -- ``apiXXDebugAndroidTest`` -- FAIL during setup. GMD tries to
|
||||
save/load an AVD *snapshot* and AEHD 2.2 cannot complete it:
|
||||
|
||||
AvdSnapshotHandler$EmulatorSnapshotCannotCreatedException: Snapshot creation timed out
|
||||
|
||||
GMD retries the snapshot ~5x, rebooting the AVD each time -- that endless reboot is the
|
||||
"cycling" that eats hours. The emulator ITSELF is healthy (8 GB RAM, sys.boot_completed=1,
|
||||
shell-responsive); only GMD's snapshot step is broken. So every LOCAL GMD task is affected:
|
||||
the coverage lanes and the /preflight api35/api36 steps. CI is unaffected -- it uses
|
||||
reactivecircus/android-emulator-runner + ``connectedDebugAndroidTest``, never GMD.
|
||||
|
||||
THE RELIABLE LOCAL PATH (this script):
|
||||
Cold-boot ONE emulator by hand with ``-no-snapshot`` (no GMD, no snapshot machinery),
|
||||
then run ``:app:connectedDebugAndroidTest`` -- the exact technique CI and ``api37_e2e.py``
|
||||
already use. We reuse a GMD-provisioned AVD by name so we don't re-download a system
|
||||
image; GMD re-provisions its own copy on its next run, so the ``-wipe-data`` cold boot
|
||||
here does not disturb it.
|
||||
|
||||
KEEP RUNS TARGETED -- THE ~114-TEST MID-SUITE WEDGE
|
||||
---------------------------------------------------
|
||||
Running the WHOLE instrumented suite (~114 tests) via ``connectedDebugAndroidTest`` on this
|
||||
box tends to wedge partway through -- the emulator stops making progress mid-run. Small,
|
||||
targeted class sets do NOT hit that wedge. That is why this helper takes an explicit
|
||||
``<fully.qualified.TestClass>[,...]`` argument and filters the run with
|
||||
``-Pandroid.testInstrumentationRunnerArguments.class=...`` instead of running everything.
|
||||
Run the class(es) you actually changed; do not use this to run the full suite (that is
|
||||
CI's / preflight's job across the API matrix).
|
||||
|
||||
FREEZE / HYGIENE RATIONALE -- WHY THE ORPHAN-KILL + TEARDOWN VERIFY ARE MANDATORY
|
||||
--------------------------------------------------------------------------------
|
||||
A hung ``adb emu kill`` (or an interrupted run) leaves a detached qemu VM process behind
|
||||
(``qemu-system-x86_64-headless.exe`` on Windows; a ``qemu-system-*`` process on *nix).
|
||||
These orphans do not show up in ``adb devices``, they keep holding the hypervisor + RAM,
|
||||
and accumulated orphans have FROZEN this machine outright. So this script:
|
||||
* PREAMBLE -- force-kills any pre-existing qemu/emulator processes and resets the adb
|
||||
server BEFORE booting, so we always start from a clean slate.
|
||||
* TEARDOWN -- ``adb emu kill``, kill the launcher we spawned, then re-check for ANY
|
||||
surviving emulator/qemu process and force-kill it (the ``-no-window`` emulator
|
||||
can leave a sibling ``emulator.exe`` that briefly outlives the qemu VM). Teardown
|
||||
runs even on Ctrl-C / error / SIGTERM (try/finally + atexit + SIGINT/SIGTERM
|
||||
handlers) and is idempotent.
|
||||
* VERIFY -- if a qemu process is STILL alive after the force-kill, the script exits
|
||||
non-zero (code 3) so the leak is never silently ignored.
|
||||
Never leave an emulator running after this script; if it exits 3, hunt the zombie down by
|
||||
hand (Windows: ``tasklist | findstr qemu`` then ``taskkill /F /IM
|
||||
qemu-system-x86_64-headless.exe``; *nix: ``pgrep -fa qemu-system`` then ``pkill -f
|
||||
qemu-system``).
|
||||
|
||||
CROSS-PLATFORM PROCESS KILL
|
||||
---------------------------
|
||||
Listing and force-killing the emulator/qemu processes is abstracted per-OS (see
|
||||
``list_procs`` / ``force_kill``): Windows uses ``tasklist`` + ``taskkill /F /IM <image>``;
|
||||
*nix uses ``ps ax`` + ``pkill -f qemu-system`` (alongside the graceful ``adb emu kill``).
|
||||
The qemu VM is the freeze-causing orphan on every platform.
|
||||
|
||||
EXIT CODES (preserved from local_instrumented.sh)
|
||||
0 tests passed
|
||||
2 usage / precondition failure
|
||||
3 a qemu zombie survived teardown -- clean it up by hand before the next run
|
||||
4 emulator never reached sys.boot_completed
|
||||
<n> connectedDebugAndroidTest's own non-zero exit code (test failures)
|
||||
|
||||
REQUIREMENTS
|
||||
* Android SDK ``emulator`` + ``adb`` on PATH.
|
||||
* A JDK 17-21 for the Gradle daemon -- AGP 9.2 fails on JDK 25+. This script pins
|
||||
JAVA_HOME to a known JDK 21 (override with LOCAL_INSTRUMENTED_JDK) because the ambient
|
||||
JAVA_HOME on the primary box points at JDK 25.
|
||||
* A free hardware hypervisor (VT-x/WHPX/AEHD/KVM/HVF). Shut down VirtualBox / other VMs
|
||||
first or the AVD hangs at 0% CPU and never reaches sys.boot_completed.
|
||||
|
||||
Overridable via environment (defaults target the primary Windows dev box):
|
||||
LOCAL_INSTRUMENTED_AVD AVD name to boot (dev36_google_apis_x86_64_Pixel_2)
|
||||
ANDROID_AVD_HOME AVD home dir (C:/Users/jasonross/.android/avd/gradle-managed)
|
||||
LOCAL_INSTRUMENTED_JDK JDK 17-21 home (Eclipse Adoptium jdk-21.0.11.10-hotspot)
|
||||
LOCAL_INSTRUMENTED_SERIAL adb serial (emulator-5554)
|
||||
LOCAL_INSTRUMENTED_BOOT_TIMEOUT boot wait seconds (300)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
IS_WINDOWS = os.name == "nt"
|
||||
|
||||
# ---- configuration (env-overridable; defaults are correct for the primary dev box) ------
|
||||
AVD_NAME = os.environ.get("LOCAL_INSTRUMENTED_AVD", "dev36_google_apis_x86_64_Pixel_2")
|
||||
AVD_HOME = os.environ.get("ANDROID_AVD_HOME", "C:/Users/jasonross/.android/avd/gradle-managed")
|
||||
JDK_HOME = os.environ.get(
|
||||
"LOCAL_INSTRUMENTED_JDK", "C:/Program Files/Eclipse Adoptium/jdk-21.0.11.10-hotspot"
|
||||
)
|
||||
SERIAL = os.environ.get("LOCAL_INSTRUMENTED_SERIAL", "emulator-5554")
|
||||
BOOT_TIMEOUT = int(os.environ.get("LOCAL_INSTRUMENTED_BOOT_TIMEOUT", "300"))
|
||||
|
||||
# Windows qemu/emulator image names (see FREEZE / HYGIENE above). The ``-headless`` variant is
|
||||
# what a ``-no-window`` emulator launches; the plain qemu name is swept too, belt-and-suspenders.
|
||||
QEMU_IMAGE = "qemu-system-x86_64-headless.exe"
|
||||
QEMU_IMAGE_ALT = "qemu-system-x86_64.exe"
|
||||
EMULATOR_IMAGE = "emulator.exe"
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3] # .claude/skills/preflight -> repo root
|
||||
GRADLEW = REPO_ROOT / ("gradlew.bat" if IS_WINDOWS else "gradlew")
|
||||
EMU_LOG = Path(tempfile.gettempdir()) / "libremail-local-instrumented-emulator.log"
|
||||
|
||||
|
||||
class _RunState:
|
||||
"""Mutable run state shared by main(), teardown(), the atexit hook and the signal
|
||||
handlers -- mirrors the bash globals EMU_PID / TEST_EXIT / ZOMBIE / TEARDOWN_DONE."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.proc: subprocess.Popen | None = None
|
||||
self.test_exit = 1
|
||||
self.zombie = False
|
||||
self.teardown_done = False
|
||||
|
||||
|
||||
_STATE = _RunState()
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(f"\n=== {msg} ===")
|
||||
|
||||
|
||||
def warn(msg: str) -> None:
|
||||
print(f"WARNING: {msg}", file=sys.stderr)
|
||||
|
||||
|
||||
def die(msg: str) -> None:
|
||||
"""Print an error and exit 2 (usage / precondition failure). Called before the teardown
|
||||
backstops are armed, so nothing has booted and there is nothing to tear down."""
|
||||
print(f"ERROR: {msg}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def cmd(tool: str, *args: str) -> list[str]:
|
||||
"""Build an argv list, wrapping Windows ``.bat``/``.cmd`` launchers (e.g. gradlew.bat)
|
||||
through ``cmd /c`` -- matching api37_e2e.py. ``.exe`` tools pass through unchanged."""
|
||||
if IS_WINDOWS and tool.lower().endswith((".bat", ".cmd")):
|
||||
return ["cmd", "/c", tool, *args]
|
||||
return [tool, *args]
|
||||
|
||||
|
||||
def _run_quiet(argv: list[str]) -> None:
|
||||
"""Run a command, discarding output and swallowing any error -- teardown/kill helpers
|
||||
must always make progress."""
|
||||
try:
|
||||
subprocess.run(argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
|
||||
def _run_capture(argv: list[str]) -> str:
|
||||
try:
|
||||
return subprocess.run(argv, capture_output=True, text=True, check=False).stdout or ""
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
|
||||
|
||||
def list_procs(*needles: str) -> str:
|
||||
"""Return the lines of currently-running processes whose name/command line contains any
|
||||
of ``needles`` (case-insensitive); empty string if none. Cross-platform stand-in for the
|
||||
.sh's ``tasklist | grep``: ``tasklist`` on Windows, ``ps ax`` on *nix."""
|
||||
out = _run_capture(["tasklist"] if IS_WINDOWS else ["ps", "ax"])
|
||||
lowered = [n.lower() for n in needles]
|
||||
return "\n".join(ln for ln in out.splitlines() if any(n in ln.lower() for n in lowered))
|
||||
|
||||
|
||||
def list_qemu() -> str:
|
||||
return list_procs("qemu")
|
||||
|
||||
|
||||
def list_emu_procs() -> str:
|
||||
return list_procs("qemu", "emulator")
|
||||
|
||||
|
||||
def force_kill(win_images: list[str], nix_patterns: list[str]) -> None:
|
||||
"""Best-effort force-kill. Windows: ``taskkill /F /IM <image> ...``. *nix: ``pkill -f
|
||||
<pattern>`` per pattern. Never raises -- teardown must always make progress."""
|
||||
if IS_WINDOWS:
|
||||
argv = ["taskkill", "/F"]
|
||||
for image in win_images:
|
||||
argv += ["/IM", image]
|
||||
_run_quiet(argv)
|
||||
else:
|
||||
for pattern in nix_patterns:
|
||||
_run_quiet(["pkill", "-f", pattern])
|
||||
|
||||
|
||||
def tail(path: Path, lines: int = 40) -> None:
|
||||
try:
|
||||
with open(path, "r", errors="replace") as handle:
|
||||
content = handle.readlines()[-lines:]
|
||||
print("".join(content), file=sys.stderr, end="")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def teardown() -> None:
|
||||
"""Kill the emulator and verify no orphaned emulator/qemu process remains. Idempotent --
|
||||
safe to call from the finally block, the atexit hook and the signal handlers (mirrors the
|
||||
.sh TEARDOWN_DONE guard). Sets _STATE.zombie if a *qemu* process survives the force-kill --
|
||||
the machine-freezing case (exit 3)."""
|
||||
if _STATE.teardown_done:
|
||||
return
|
||||
_STATE.teardown_done = True
|
||||
|
||||
log("Teardown: killing emulator and verifying no orphaned emulator/qemu remains")
|
||||
adb = shutil.which("adb")
|
||||
if adb:
|
||||
_run_quiet(cmd(adb, "-s", SERIAL, "emu", "kill"))
|
||||
time.sleep(2)
|
||||
|
||||
# Belt-and-suspenders: kill the emulator launcher process we started, if still alive.
|
||||
proc = _STATE.proc
|
||||
if proc is not None and proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=1)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
# Reap any lingering emulator/qemu process, then verify. Unlike the original .sh -- which
|
||||
# swept qemu ONLY -- we also force-kill the emulator *launcher* image: on Windows the
|
||||
# ``-no-window`` emulator spawns a sibling ``emulator.exe`` that is NOT the Popen child we
|
||||
# tracked and outlives both it and the qemu VM by a few seconds, so a qemu-only sweep
|
||||
# returns while it is still shutting down -- an orphan the freeze-safety rule forbids. So we
|
||||
# trigger on any emulator-or-qemu survivor and taskkill the launcher too.
|
||||
if list_emu_procs():
|
||||
warn("emulator/qemu still present after 'adb emu kill'; force-killing:")
|
||||
print(list_emu_procs(), file=sys.stderr)
|
||||
force_kill([QEMU_IMAGE, QEMU_IMAGE_ALT, EMULATOR_IMAGE], ["qemu-system"])
|
||||
time.sleep(2)
|
||||
# A surviving QEMU is the machine-freezing zombie (exit 3); a stray launcher is not.
|
||||
remaining = list_qemu()
|
||||
if remaining:
|
||||
warn("qemu ZOMBIE survived teardown -- kill it by hand or the machine may freeze:")
|
||||
print(remaining, file=sys.stderr)
|
||||
_STATE.zombie = True
|
||||
|
||||
if adb:
|
||||
_run_quiet(cmd(adb, "kill-server"))
|
||||
|
||||
|
||||
def orphan_kill_preamble(adb: str) -> None:
|
||||
"""Force-kill any pre-existing qemu/emulator processes and reset the adb server, so we
|
||||
always cold-boot from a clean slate."""
|
||||
log("Orphan-kill preamble: ensuring a clean slate before boot")
|
||||
existing = list_emu_procs()
|
||||
if existing:
|
||||
warn("Pre-existing emulator/qemu processes found -- force-killing them first:")
|
||||
print(existing, file=sys.stderr)
|
||||
force_kill([QEMU_IMAGE, EMULATOR_IMAGE], ["qemu-system"])
|
||||
time.sleep(2)
|
||||
else:
|
||||
print("No pre-existing qemu/emulator processes.")
|
||||
_run_quiet(cmd(adb, "kill-server"))
|
||||
_run_quiet(cmd(adb, "start-server"))
|
||||
|
||||
|
||||
def start_emulator(emulator: str) -> subprocess.Popen:
|
||||
"""Cold-boot ONE emulator by hand (no GMD, no snapshot), logging to EMU_LOG. Records the
|
||||
launcher process in _STATE so teardown can reap it even if we are interrupted next."""
|
||||
log(f"Cold-booting @{AVD_NAME} (no GMD, no snapshot); log -> {EMU_LOG}")
|
||||
flags = [
|
||||
f"@{AVD_NAME}",
|
||||
"-no-window", "-no-snapshot", "-no-boot-anim", "-no-audio",
|
||||
"-gpu", "auto-no-window", "-cores", "8", "-wipe-data",
|
||||
]
|
||||
logf = open(EMU_LOG, "wb") # noqa: SIM115 - handed to the child; parent copy closed below
|
||||
try:
|
||||
proc = subprocess.Popen(cmd(emulator, *flags), stdout=logf, stderr=subprocess.STDOUT)
|
||||
finally:
|
||||
logf.close() # the child inherited its own fd; the parent's copy is no longer needed
|
||||
_STATE.proc = proc
|
||||
print(f"emulator launcher pid={proc.pid}")
|
||||
return proc
|
||||
|
||||
|
||||
def wait_for_boot(adb: str, proc: subprocess.Popen, timeout: int) -> bool:
|
||||
"""Poll ``adb get-state`` + ``getprop sys.boot_completed`` until the emulator is up, or the
|
||||
launcher dies, or ``timeout`` seconds elapse. Mirrors the .sh boot loop."""
|
||||
print(f"Waiting up to {timeout}s for sys.boot_completed on {SERIAL}...")
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if proc.poll() is not None:
|
||||
warn("emulator process exited during boot; last log lines:")
|
||||
tail(EMU_LOG, 40)
|
||||
return False
|
||||
state = _run_capture(cmd(adb, "-s", SERIAL, "get-state")).strip()
|
||||
if state == "device":
|
||||
booted = _run_capture(
|
||||
cmd(adb, "-s", SERIAL, "shell", "getprop", "sys.boot_completed")
|
||||
).strip()
|
||||
if booted == "1":
|
||||
return True
|
||||
time.sleep(3)
|
||||
return False
|
||||
|
||||
|
||||
def prepare_focus(adb: str) -> None:
|
||||
# Force the emulator to grant the app window focus BEFORE the suite, then gate on it -- the
|
||||
# SAME shared helper CI's e2e / e2e-preview jobs invoke (issue #468), so local preflight
|
||||
# exercises the identical fix. It wakes the display, dismisses + disables the keyguard, keeps
|
||||
# the screen on, disables animations, and waits for a focused window. Best-effort: fall back
|
||||
# to the legacy `input keyevent 82` nudge if the shared helper is somehow missing.
|
||||
gate = REPO_ROOT / ".github" / "scripts" / "emulator_focus_gate.py"
|
||||
if gate.is_file():
|
||||
subprocess.run([sys.executable, str(gate), "--adb", adb, "--serial", SERIAL], check=False)
|
||||
else:
|
||||
_run_quiet(cmd(adb, "-s", SERIAL, "shell", "input", "keyevent", "82"))
|
||||
|
||||
|
||||
def run_tests(test_classes: str) -> int:
|
||||
"""Run :app:connectedDebugAndroidTest filtered to ``test_classes`` from the repo root
|
||||
(JAVA_HOME / ANDROID_AVD_HOME are already in the environment)."""
|
||||
log(f"Running :app:connectedDebugAndroidTest for: {test_classes}")
|
||||
print(f"JAVA_HOME={os.environ.get('JAVA_HOME', '')}")
|
||||
return subprocess.run(
|
||||
cmd(
|
||||
str(GRADLEW),
|
||||
":app:connectedDebugAndroidTest",
|
||||
f"-Pandroid.testInstrumentationRunnerArguments.class={test_classes}",
|
||||
"--stacktrace",
|
||||
),
|
||||
cwd=str(REPO_ROOT),
|
||||
check=False,
|
||||
).returncode
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="local_instrumented.py",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
description=(
|
||||
"Cold-boot ONE emulator (no GMD, no snapshot) and run "
|
||||
":app:connectedDebugAndroidTest filtered to the given instrumented test class(es)."
|
||||
),
|
||||
epilog=(
|
||||
"Keep the class set small and targeted -- the full ~114-test suite tends to wedge\n"
|
||||
"mid-run on this box (see the module docstring). The full matrix is CI's job.\n"
|
||||
"Example:\n"
|
||||
" python .claude/skills/preflight/local_instrumented.py \\\n"
|
||||
" org.libremail.ui.compose.ComposeScreenE2ETest,org.libremail.ui.compose.RecipientChipTest"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"test_classes",
|
||||
metavar="TEST_CLASSES",
|
||||
help=(
|
||||
"Comma-separated fully-qualified instrumented test class(es), no spaces "
|
||||
"(e.g. org.libremail.a.FooTest,org.libremail.b.BarTest)."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# ---- preconditions (before arming teardown; nothing has booted yet) ------------------
|
||||
emulator = shutil.which("emulator")
|
||||
adb = shutil.which("adb")
|
||||
if not emulator:
|
||||
die("emulator not on PATH (install Android SDK emulator).")
|
||||
if not adb:
|
||||
die("adb not on PATH (install Android SDK platform-tools).")
|
||||
kill_tool = "taskkill" if IS_WINDOWS else "pkill"
|
||||
if not shutil.which(kill_tool):
|
||||
die(f"{kill_tool} not found -- required to force-kill orphaned emulator/qemu processes.")
|
||||
if not GRADLEW.is_file():
|
||||
die(f"gradlew not found at {GRADLEW}.")
|
||||
if not os.path.isdir(JDK_HOME):
|
||||
die(f"JDK 17-21 not found at '{JDK_HOME}'. Set LOCAL_INSTRUMENTED_JDK.")
|
||||
if not os.path.isfile(os.path.join(AVD_HOME, AVD_NAME + ".ini")):
|
||||
die(
|
||||
f"AVD '{AVD_NAME}' not found under '{AVD_HOME}'. "
|
||||
"Set LOCAL_INSTRUMENTED_AVD / ANDROID_AVD_HOME. "
|
||||
"(GMD AVDs are created by any local apiXXDebugAndroidTest run.)"
|
||||
)
|
||||
|
||||
# Move gradlew's working dir to this tree's repo root (below) and pin JAVA_HOME/AVD home,
|
||||
# exactly like the .sh -- the ambient JAVA_HOME on this box points at JDK 25 (AGP-incompatible).
|
||||
os.environ["JAVA_HOME"] = JDK_HOME
|
||||
os.environ["ANDROID_AVD_HOME"] = AVD_HOME
|
||||
|
||||
# ---- arm teardown backstops BEFORE touching the emulator -----------------------------
|
||||
# try/finally is the primary path; atexit covers sys.exit()/unhandled-exception exits; the
|
||||
# signal handlers make SIGINT/SIGTERM tear down too (Python does not raise on SIGTERM by
|
||||
# default). teardown() is idempotent, so firing from several paths is safe (mirrors the
|
||||
# .sh's ``trap teardown EXIT INT TERM`` + TEARDOWN_DONE guard).
|
||||
atexit.register(teardown)
|
||||
|
||||
def _signal_teardown(signum: int, _frame: object) -> None:
|
||||
teardown()
|
||||
sys.exit(128 + signum)
|
||||
|
||||
signal.signal(signal.SIGINT, _signal_teardown)
|
||||
if hasattr(signal, "SIGTERM"):
|
||||
signal.signal(signal.SIGTERM, _signal_teardown)
|
||||
|
||||
boot_failed = False
|
||||
try:
|
||||
orphan_kill_preamble(adb)
|
||||
proc = start_emulator(emulator)
|
||||
if wait_for_boot(adb, proc, BOOT_TIMEOUT):
|
||||
print("Emulator booted.")
|
||||
prepare_focus(adb)
|
||||
_STATE.test_exit = run_tests(args.test_classes)
|
||||
else:
|
||||
warn(f"Emulator did not reach sys.boot_completed within {BOOT_TIMEOUT}s.")
|
||||
tail(EMU_LOG, 40)
|
||||
boot_failed = True
|
||||
finally:
|
||||
teardown()
|
||||
|
||||
if boot_failed:
|
||||
return 4
|
||||
if _STATE.zombie:
|
||||
warn(
|
||||
"Exiting 3: a qemu zombie was left behind (see above) -- "
|
||||
"clean it up before the next run."
|
||||
)
|
||||
return 3
|
||||
if _STATE.test_exit != 0:
|
||||
warn(
|
||||
f"connectedDebugAndroidTest failed (exit {_STATE.test_exit}). "
|
||||
"Report: app/build/reports/androidTests/connected/"
|
||||
)
|
||||
return _STATE.test_exit
|
||||
log(f"PASS -- instrumented tests green for: {args.test_classes}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,253 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# local_instrumented.sh — reliable LOCAL instrumented / E2E test runner for LibreMail.
|
||||
#
|
||||
# Usage: local_instrumented.sh <fully.qualified.TestClass>[,<Class2>,...]
|
||||
# Example:
|
||||
# .claude/skills/preflight/local_instrumented.sh \
|
||||
# org.libremail.ui.compose.ComposeScreenE2ETest
|
||||
# .claude/skills/preflight/local_instrumented.sh \
|
||||
# org.libremail.ui.compose.ComposeScreenE2ETest,org.libremail.ui.compose.RecipientChipTest
|
||||
#
|
||||
# =============================================================================
|
||||
# WHY THIS SCRIPT EXISTS (issue #269)
|
||||
# -----------------------------------------------------------------------------
|
||||
# On this machine (Windows + the AEHD 2.2 hypervisor) the Gradle Managed Device
|
||||
# (GMD) instrumented tasks — `apiXXDebugAndroidTest` — FAIL during setup. GMD tries
|
||||
# to save/load an AVD *snapshot* and AEHD 2.2 cannot complete it:
|
||||
#
|
||||
# AvdSnapshotHandler$EmulatorSnapshotCannotCreatedException: Snapshot creation timed out
|
||||
#
|
||||
# GMD retries the snapshot ~5x, rebooting the AVD each time — that endless reboot is
|
||||
# the "cycling" that eats hours. The emulator ITSELF is healthy (8 GB RAM,
|
||||
# `sys.boot_completed=1`, shell-responsive); only GMD's snapshot step is broken. So
|
||||
# every LOCAL GMD task is affected: coverage lanes 3/5 (#248/#250) and the /preflight
|
||||
# api35/api36 steps (#266). CI is unaffected — it uses reactivecircus/android-emulator-runner
|
||||
# + `connectedDebugAndroidTest`, never GMD.
|
||||
#
|
||||
# THE RELIABLE LOCAL PATH (this script):
|
||||
# Cold-boot ONE emulator by hand with `-no-snapshot` (no GMD, no snapshot machinery),
|
||||
# then run `:app:connectedDebugAndroidTest` — the exact technique CI and
|
||||
# `api37_e2e.py` already use. We reuse a GMD-provisioned AVD by name so we don't have
|
||||
# to re-download a system image; GMD re-provisions its own copy on its next run, so
|
||||
# the `-wipe-data` cold boot here does not disturb it.
|
||||
#
|
||||
# =============================================================================
|
||||
# KEEP RUNS TARGETED — THE ~114-TEST MID-SUITE WEDGE
|
||||
# -----------------------------------------------------------------------------
|
||||
# Running the WHOLE instrumented suite (~114 tests) via `connectedDebugAndroidTest`
|
||||
# on this box tends to wedge partway through — the emulator stops making progress
|
||||
# mid-run. Small, targeted class sets do NOT hit that wedge. That is why this helper
|
||||
# takes an explicit `<fully.qualified.TestClass>[,...]` argument and filters the run
|
||||
# with `-Pandroid.testInstrumentationRunnerArguments.class=...` instead of running
|
||||
# everything. Run the class(es) you actually changed; do not use this to run the full
|
||||
# suite (that is CI's / preflight's job across the API matrix).
|
||||
#
|
||||
# =============================================================================
|
||||
# FREEZE / HYGIENE RATIONALE — WHY THE ORPHAN-KILL + TEARDOWN VERIFY ARE MANDATORY
|
||||
# -----------------------------------------------------------------------------
|
||||
# A hung `adb emu kill` (or an interrupted run) leaves a detached
|
||||
# `qemu-system-x86_64-headless.exe` behind. These orphans do not show up in
|
||||
# `adb devices`, they keep holding the hypervisor + RAM, and accumulated orphans have
|
||||
# FROZEN this machine outright. So this script:
|
||||
# * PREAMBLE — force-kills any pre-existing qemu/emulator processes and resets the
|
||||
# adb server BEFORE booting, so we always start from a clean slate.
|
||||
# * TEARDOWN — `adb emu kill`, then re-checks `tasklist` for qemu and force-kills any
|
||||
# survivor. The teardown runs even on Ctrl-C / error (EXIT/INT/TERM trap).
|
||||
# * VERIFY — if a qemu process is STILL alive after the force-kill, the script exits
|
||||
# non-zero (code 3) so the leak is never silently ignored.
|
||||
# Never leave an emulator running after this script; if it exits 3, hunt the zombie
|
||||
# down by hand (`tasklist | grep -i qemu`; `taskkill //F //IM qemu-system-x86_64-headless.exe`).
|
||||
#
|
||||
# =============================================================================
|
||||
# REQUIREMENTS
|
||||
# * Git Bash (this is a bash script; it shells out to Windows `tasklist`/`taskkill`).
|
||||
# * Android SDK `emulator` + `adb` on PATH (SDK at C:\Android here).
|
||||
# * A JDK 17–21 for the Gradle daemon — AGP 9.2 fails on JDK 25+. This script pins
|
||||
# JAVA_HOME to a known JDK 21 (override with LOCAL_INSTRUMENTED_JDK) because the
|
||||
# ambient JAVA_HOME on this box points at JDK 25.
|
||||
# * A free hardware hypervisor (VT-x/WHPX/AEHD). Shut down VirtualBox / other VMs first
|
||||
# or the AVD hangs at 0% CPU and never reaches sys.boot_completed.
|
||||
#
|
||||
# Overridable via environment (defaults target THIS machine):
|
||||
# LOCAL_INSTRUMENTED_AVD AVD name to boot (dev36_google_apis_x86_64_Pixel_2)
|
||||
# ANDROID_AVD_HOME AVD home dir (C:/Users/jasonross/.android/avd/gradle-managed)
|
||||
# LOCAL_INSTRUMENTED_JDK JDK 17–21 home (Eclipse Adoptium jdk-21.0.11.10-hotspot)
|
||||
# LOCAL_INSTRUMENTED_SERIAL adb serial (emulator-5554)
|
||||
# LOCAL_INSTRUMENTED_BOOT_TIMEOUT boot wait seconds (300)
|
||||
# =============================================================================
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# ---- configuration (env-overridable; defaults are correct for this machine) -----------
|
||||
AVD_NAME="${LOCAL_INSTRUMENTED_AVD:-dev36_google_apis_x86_64_Pixel_2}"
|
||||
AVD_HOME="${ANDROID_AVD_HOME:-C:/Users/jasonross/.android/avd/gradle-managed}"
|
||||
JDK_HOME="${LOCAL_INSTRUMENTED_JDK:-C:/Program Files/Eclipse Adoptium/jdk-21.0.11.10-hotspot}"
|
||||
SERIAL="${LOCAL_INSTRUMENTED_SERIAL:-emulator-5554}"
|
||||
BOOT_TIMEOUT="${LOCAL_INSTRUMENTED_BOOT_TIMEOUT:-300}"
|
||||
QEMU_IMAGE="qemu-system-x86_64-headless.exe"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" # .claude/skills/preflight -> repo root
|
||||
GRADLEW="${REPO_ROOT}/gradlew"
|
||||
EMU_LOG="${TMPDIR:-/tmp}/libremail-local-instrumented-emulator.log"
|
||||
|
||||
EMU_PID=""
|
||||
TEST_EXIT=1
|
||||
ZOMBIE=0
|
||||
TEARDOWN_DONE=0
|
||||
|
||||
log() { printf '\n=== %s ===\n' "$*"; }
|
||||
warn() { printf 'WARNING: %s\n' "$*" >&2; }
|
||||
die() { printf 'ERROR: %s\n' "$*" >&2; exit 2; }
|
||||
|
||||
# All emulator/qemu processes Windows currently sees (empty string if none).
|
||||
list_emu_procs() { tasklist 2>/dev/null | grep -iE 'qemu|emulator' || true; }
|
||||
list_qemu() { tasklist 2>/dev/null | grep -i 'qemu' || true; }
|
||||
|
||||
# ---- argument parsing -----------------------------------------------------------------
|
||||
TEST_CLASSES="${1:-}"
|
||||
if [[ -z "${TEST_CLASSES}" ]]; then
|
||||
cat >&2 <<'USAGE'
|
||||
usage: local_instrumented.sh <fully.qualified.TestClass>[,<Class2>,...]
|
||||
|
||||
Cold-boots ONE emulator (no GMD, no snapshot) and runs :app:connectedDebugAndroidTest
|
||||
filtered to the given instrumented test class(es). Keep the set small and targeted —
|
||||
see the header for the ~114-test mid-suite wedge.
|
||||
USAGE
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# ---- preconditions --------------------------------------------------------------------
|
||||
command -v emulator >/dev/null 2>&1 || die "emulator not on PATH (install Android SDK emulator)."
|
||||
command -v adb >/dev/null 2>&1 || die "adb not on PATH (install Android SDK platform-tools)."
|
||||
command -v tasklist >/dev/null 2>&1 || die "tasklist not found — this helper targets Windows/Git Bash."
|
||||
[[ -f "${GRADLEW}" ]] || die "gradlew not found at ${GRADLEW}."
|
||||
[[ -d "${JDK_HOME}" ]] || die "JDK 17-21 not found at '${JDK_HOME}'. Set LOCAL_INSTRUMENTED_JDK."
|
||||
[[ -f "${AVD_HOME}/${AVD_NAME}.ini" ]] || \
|
||||
die "AVD '${AVD_NAME}' not found under '${AVD_HOME}'. Set LOCAL_INSTRUMENTED_AVD / ANDROID_AVD_HOME.
|
||||
(GMD AVDs are created by any local apiXXDebugAndroidTest run.)"
|
||||
|
||||
# Move into the resolved repo/worktree root before invoking gradlew. GRADLEW above is an
|
||||
# absolute path, but the gradlew wrapper script picks the *project* to build from the
|
||||
# process's current directory, not from its own script location — so without this `cd`,
|
||||
# running this helper from a different tree (e.g. another worktree, or the main repo
|
||||
# while iterating on a worktree's copy of this script) silently builds the CALLER's CWD
|
||||
# tree instead of this one (issue #284).
|
||||
cd "${REPO_ROOT}" || die "Could not cd to repo root '${REPO_ROOT}'."
|
||||
|
||||
export JAVA_HOME="${JDK_HOME}"
|
||||
export ANDROID_AVD_HOME="${AVD_HOME}"
|
||||
|
||||
# ---- teardown: always runs (normal exit, error, or Ctrl-C) ----------------------------
|
||||
teardown() {
|
||||
[[ "${TEARDOWN_DONE}" == "1" ]] && return 0
|
||||
TEARDOWN_DONE=1
|
||||
|
||||
log "Teardown: killing emulator and verifying no orphaned qemu remains"
|
||||
adb -s "${SERIAL}" emu kill >/dev/null 2>&1 || true
|
||||
sleep 2
|
||||
|
||||
# Belt-and-suspenders: kill the emulator launcher process we started, if still alive.
|
||||
if [[ -n "${EMU_PID}" ]] && kill -0 "${EMU_PID}" 2>/dev/null; then
|
||||
kill "${EMU_PID}" 2>/dev/null || true
|
||||
sleep 1
|
||||
kill -9 "${EMU_PID}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Verify: any surviving qemu is a machine-freezing zombie — force-kill and re-check.
|
||||
local remaining
|
||||
remaining="$(list_qemu)"
|
||||
if [[ -n "${remaining}" ]]; then
|
||||
warn "qemu still present after 'adb emu kill'; force-killing:"
|
||||
printf '%s\n' "${remaining}" >&2
|
||||
taskkill //F //IM "${QEMU_IMAGE}" >/dev/null 2>&1 || true
|
||||
# Sweep any other stray qemu-system image name, too.
|
||||
taskkill //F //IM "qemu-system-x86_64.exe" >/dev/null 2>&1 || true
|
||||
sleep 2
|
||||
remaining="$(list_qemu)"
|
||||
if [[ -n "${remaining}" ]]; then
|
||||
warn "qemu ZOMBIE survived teardown — kill it by hand or the machine may freeze:"
|
||||
printf '%s\n' "${remaining}" >&2
|
||||
ZOMBIE=1
|
||||
fi
|
||||
fi
|
||||
|
||||
adb kill-server >/dev/null 2>&1 || true
|
||||
}
|
||||
trap teardown EXIT INT TERM
|
||||
|
||||
# ---- 1. orphan-kill preamble ----------------------------------------------------------
|
||||
log "Orphan-kill preamble: ensuring a clean slate before boot"
|
||||
existing="$(list_emu_procs)"
|
||||
if [[ -n "${existing}" ]]; then
|
||||
warn "Pre-existing emulator/qemu processes found — force-killing them first:"
|
||||
printf '%s\n' "${existing}" >&2
|
||||
taskkill //F //IM "${QEMU_IMAGE}" //IM "emulator.exe" >/dev/null 2>&1 || true
|
||||
sleep 2
|
||||
else
|
||||
echo "No pre-existing qemu/emulator processes."
|
||||
fi
|
||||
adb kill-server >/dev/null 2>&1 || true
|
||||
adb start-server >/dev/null 2>&1 || true
|
||||
|
||||
# ---- 2. cold-boot ONE emulator (no snapshot) ------------------------------------------
|
||||
log "Cold-booting @${AVD_NAME} (no GMD, no snapshot); log -> ${EMU_LOG}"
|
||||
emulator "@${AVD_NAME}" \
|
||||
-no-window -no-snapshot -no-boot-anim -no-audio \
|
||||
-gpu auto-no-window -cores 8 -wipe-data \
|
||||
>"${EMU_LOG}" 2>&1 &
|
||||
EMU_PID=$!
|
||||
echo "emulator launcher pid=${EMU_PID}"
|
||||
|
||||
echo "Waiting up to ${BOOT_TIMEOUT}s for sys.boot_completed on ${SERIAL}..."
|
||||
deadline=$(( $(date +%s) + BOOT_TIMEOUT ))
|
||||
booted=0
|
||||
while (( $(date +%s) < deadline )); do
|
||||
if ! kill -0 "${EMU_PID}" 2>/dev/null; then
|
||||
warn "emulator process exited during boot; last log lines:"
|
||||
tail -n 40 "${EMU_LOG}" >&2 || true
|
||||
break
|
||||
fi
|
||||
state="$(adb -s "${SERIAL}" get-state 2>/dev/null | tr -d '\r')"
|
||||
if [[ "${state}" == "device" ]]; then
|
||||
bc="$(adb -s "${SERIAL}" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r\n ')"
|
||||
if [[ "${bc}" == "1" ]]; then booted=1; break; fi
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
|
||||
if [[ "${booted}" != "1" ]]; then
|
||||
warn "Emulator did not reach sys.boot_completed within ${BOOT_TIMEOUT}s."
|
||||
tail -n 40 "${EMU_LOG}" >&2 || true
|
||||
# teardown runs via the EXIT trap; surface a boot failure distinctly.
|
||||
exit 4
|
||||
fi
|
||||
echo "Emulator booted."
|
||||
|
||||
# Dismiss the keyguard (mirrors CI + api37_e2e.py). Best-effort: a cold -wipe-data boot
|
||||
# rarely needs it, and the input service can lose a race right after boot.
|
||||
adb -s "${SERIAL}" shell input keyevent 82 >/dev/null 2>&1 || true
|
||||
|
||||
# ---- 3. run the targeted instrumented tests -------------------------------------------
|
||||
log "Running :app:connectedDebugAndroidTest for: ${TEST_CLASSES}"
|
||||
echo "JAVA_HOME=${JAVA_HOME}"
|
||||
"${GRADLEW}" :app:connectedDebugAndroidTest \
|
||||
"-Pandroid.testInstrumentationRunnerArguments.class=${TEST_CLASSES}" \
|
||||
--stacktrace
|
||||
TEST_EXIT=$?
|
||||
|
||||
# ---- 4. teardown + verify, then exit --------------------------------------------------
|
||||
teardown
|
||||
|
||||
if (( ZOMBIE != 0 )); then
|
||||
warn "Exiting 3: a qemu zombie was left behind (see above) — clean it up before the next run."
|
||||
exit 3
|
||||
fi
|
||||
if (( TEST_EXIT != 0 )); then
|
||||
warn "connectedDebugAndroidTest failed (exit ${TEST_EXIT}). Report: app/build/reports/androidTests/connected/"
|
||||
exit "${TEST_EXIT}"
|
||||
fi
|
||||
log "PASS — instrumented tests green for: ${TEST_CLASSES}"
|
||||
exit 0
|
||||
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""emulator_focus_gate.py -- make a booted emulator reliably grant the app window focus
|
||||
BEFORE an instrumented UI suite runs, then GATE on that state (issue #468).
|
||||
|
||||
WHY THIS EXISTS (issue #468 -- the environmental app-window-focus flake)
|
||||
------------------------------------------------------------------------
|
||||
Intermittently, on the CI emulator the launched activity window has
|
||||
``has-window-focus=false`` for the WHOLE instrumented run, so Espresso's ``RootViewPicker``
|
||||
(used by ``onView(...).check()``, ``Intents.intended()``, ``Espresso.pressBack()`` and
|
||||
focus-dependent clipboard reads) waits 10s for a focused root and times out --
|
||||
``RootViewWithoutFocusException``. It fails EVERY window-focus-dependent test at once while
|
||||
the ~280 pure-Compose semantics tests (which do not need window focus) pass. Root-cause
|
||||
evidence from a failing ``E2E (35)`` leg (PR #470, run 28985259521): across the entire
|
||||
captured logcat ``has-window-focus=true`` appears ZERO times and both the first attempt and
|
||||
the once-retry fail identically -- i.e. the window NEVER gains focus for the session, a
|
||||
persistent environmental state, not a per-test transient.
|
||||
|
||||
The prior mitigation was a single fire-and-forget ``adb shell input keyevent 82`` (MENU)
|
||||
right after ``sys.boot_completed=1``. On modern Android (API 30+) MENU does NOT reliably
|
||||
dismiss the keyguard, and when it is delivered before SystemUI/keyguard finishes coming up it
|
||||
is simply dropped ("no focused window"). The insecure keyguard / non-interactive display then
|
||||
persists and no app window ever takes focus -- hence the intermittent, whole-leg flake.
|
||||
|
||||
WHAT THIS DOES
|
||||
--------------
|
||||
A single shared mechanism invoked identically by every E2E job (the ``e2e`` API 29-36 matrix
|
||||
AND the ``e2e-preview`` API 37 job in ``.github/workflows/ci.yml``) and by the local preflight
|
||||
runners (``local_instrumented.py`` / ``api37_e2e.py``), so the fix cannot drift between them:
|
||||
|
||||
1. PREPARE the device so an app window CAN take focus, and keep it that way for the whole
|
||||
run (all best-effort; a missing service right after boot must never abort the leg):
|
||||
* ``input keyevent WAKEUP`` (224) -- force the display INTERACTIVE (never toggles it
|
||||
off the way POWER would).
|
||||
* ``wm dismiss-keyguard`` -- dismiss the (insecure) keyguard now.
|
||||
* ``locksettings set-disabled true`` -- disable the lock screen for the session so it
|
||||
cannot re-curtain the app window mid-run.
|
||||
* ``svc power stayon true`` + a max ``screen_off_timeout`` -- never sleep during the run.
|
||||
* ``input keyevent 82`` (MENU) -- legacy nudge, kept harmless for parity with #454.
|
||||
* zero the three animation scales -- deterministic UI tests (this also gives the
|
||||
``e2e-preview`` job the animation-disable the matrix already had -- uniformly).
|
||||
2. GATE: poll ``dumpsys power`` + ``dumpsys window`` until the device is interactive
|
||||
(``mWakefulness=Awake``) AND a real window holds input focus (``mCurrentFocus`` is a
|
||||
``Window{...}``, not ``null``) -- i.e. the exact precondition ``RootViewPicker`` needs --
|
||||
re-issuing the wake / dismiss-keyguard nudges each iteration so a lost race self-heals.
|
||||
|
||||
The gate is SOFT: it waits up to ``--timeout`` seconds and then proceeds regardless, printing a
|
||||
GitHub ``::warning::`` annotation and the final device state if it never confirmed focus (the
|
||||
determinism comes from the PREPARE actions + the wait; a parsing quirk on some API level must
|
||||
not convert an otherwise-fine leg into a hard failure -- the real tests remain the arbiter).
|
||||
It always prints the final ``mWakefulness`` / ``mCurrentFocus`` / keyguard state so a genuine
|
||||
environmental failure is diagnosable from the step log without downloading artifacts.
|
||||
|
||||
Pure standard library, cross-platform (Windows / Linux / macOS): ``adb`` is invoked via
|
||||
subprocess. The readiness parser (``evaluate_readiness``) is a pure function, unit-tested by
|
||||
``test_emulator_focus_gate.py`` (run by the ``traffic-control-tests`` CI job).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import NamedTuple
|
||||
|
||||
# WAKEUP (not POWER): guarantees the display ends up INTERACTIVE. POWER (26) toggles, so it
|
||||
# would turn an already-on display OFF. MENU (82) is kept only as a legacy parity nudge.
|
||||
KEYCODE_WAKEUP = "224"
|
||||
KEYCODE_MENU = "82"
|
||||
# Max int -- effectively "never" auto-sleep the screen during the suite.
|
||||
SCREEN_OFF_TIMEOUT_MS = "2147483647"
|
||||
DEFAULT_TIMEOUT_S = 90
|
||||
POLL_INTERVAL_S = 2
|
||||
|
||||
|
||||
class Readiness(NamedTuple):
|
||||
"""Outcome of parsing ``dumpsys power`` + ``dumpsys window`` for focus readiness."""
|
||||
|
||||
ready: bool
|
||||
awake: bool
|
||||
focus_state: str # 'focused' | 'unfocused' | 'unknown'
|
||||
focus_value: str # the mCurrentFocus / mFocusedWindow token, or ''
|
||||
keyguard_state: str # 'showing' | 'not_showing' | 'unknown'
|
||||
|
||||
@property
|
||||
def summary(self) -> str:
|
||||
return (
|
||||
f"awake={self.awake} focus={self.focus_state}"
|
||||
f"({self.focus_value or '-'}) keyguard={self.keyguard_state}"
|
||||
)
|
||||
|
||||
|
||||
def _is_awake(power_out: str) -> bool:
|
||||
"""True if ``dumpsys power`` reports an INTERACTIVE display. ``mWakefulness=Awake`` is the
|
||||
stable signal across API 29-37; ``Display Power: state=ON`` / ``mInteractive=true`` are
|
||||
accepted as fallbacks for dump-format drift."""
|
||||
return bool(
|
||||
re.search(r"mWakefulness=Awake\b", power_out)
|
||||
or re.search(r"Display Power:\s*state=ON\b", power_out)
|
||||
or re.search(r"mInteractive=true\b", power_out)
|
||||
)
|
||||
|
||||
|
||||
def _focus(window_out: str) -> tuple[str, str]:
|
||||
"""Classify the current input focus from ``dumpsys window``.
|
||||
|
||||
Returns ``(state, value)`` where state is 'focused' (a non-null ``Window{...}`` holds
|
||||
focus -- what RootViewPicker needs), 'unfocused' (focus is explicitly ``null`` -- asleep /
|
||||
keyguard-curtained / no focusable window), or 'unknown' (the field is absent on this dump
|
||||
format). ``mCurrentFocus`` is preferred; ``mFocusedWindow`` is the fallback field name."""
|
||||
tokens = re.findall(r"mCurrentFocus=(\S+)", window_out)
|
||||
if not tokens:
|
||||
tokens = re.findall(r"mFocusedWindow=(\S+)", window_out)
|
||||
if not tokens:
|
||||
return ("unknown", "")
|
||||
non_null = [t for t in tokens if t != "null"]
|
||||
if non_null:
|
||||
return ("focused", non_null[0])
|
||||
return ("unfocused", "null")
|
||||
|
||||
|
||||
def _keyguard(window_out: str) -> str:
|
||||
"""Best-effort keyguard state from ``dumpsys window``: 'showing' / 'not_showing' /
|
||||
'unknown'. Informational for the summary, plus a fallback readiness signal when the focus
|
||||
field is absent. Field names vary by API level, so several are accepted."""
|
||||
match = re.search(
|
||||
r"(?:mShowingLockscreen|mDreamingLockscreen|isKeyguardShowing|"
|
||||
r"mKeyguardShowing|keyguardShowing|mKeyguardOccluded)=(true|false)",
|
||||
window_out,
|
||||
)
|
||||
if not match:
|
||||
return "unknown"
|
||||
return "showing" if match.group(1) == "true" else "not_showing"
|
||||
|
||||
|
||||
def evaluate_readiness(power_out: str, window_out: str) -> Readiness:
|
||||
"""Pure decision core (unit-tested). The device is READY for a focus-dependent UI suite
|
||||
when it is interactive AND a real window holds input focus. When the focus field is absent
|
||||
on a given dump format, fall back to "interactive AND keyguard explicitly not showing" so a
|
||||
format quirk cannot hang the gate forever."""
|
||||
awake = _is_awake(power_out)
|
||||
focus_state, focus_value = _focus(window_out)
|
||||
keyguard_state = _keyguard(window_out)
|
||||
ready = awake and (
|
||||
focus_state == "focused"
|
||||
or (focus_state == "unknown" and keyguard_state == "not_showing")
|
||||
)
|
||||
return Readiness(ready, awake, focus_state, focus_value, keyguard_state)
|
||||
|
||||
|
||||
def _adb_base(adb: str, serial: str | None) -> list[str]:
|
||||
return [adb, "-s", serial] if serial else [adb]
|
||||
|
||||
|
||||
def _adb_quiet(adb: str, serial: str | None, *args: str) -> None:
|
||||
"""Run an ``adb`` command, swallowing output and any error -- every prepare nudge is
|
||||
best-effort (a service can lose a race right after boot; a missing tool must not abort)."""
|
||||
try:
|
||||
subprocess.run(
|
||||
_adb_base(adb, serial) + list(args),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
|
||||
def _adb_capture(adb: str, serial: str | None, *args: str) -> str:
|
||||
try:
|
||||
return (
|
||||
subprocess.run(
|
||||
_adb_base(adb, serial) + list(args),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=30,
|
||||
).stdout
|
||||
or ""
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
|
||||
|
||||
def nudge_focus(adb: str, serial: str | None) -> None:
|
||||
"""Wake the display + dismiss the keyguard. Cheap and idempotent, so it is re-issued every
|
||||
poll iteration to self-heal a nudge that lost the post-boot race with SystemUI/keyguard."""
|
||||
_adb_quiet(adb, serial, "shell", "input", "keyevent", KEYCODE_WAKEUP)
|
||||
_adb_quiet(adb, serial, "shell", "wm", "dismiss-keyguard")
|
||||
|
||||
|
||||
def prepare_device(adb: str, serial: str | None) -> None:
|
||||
"""One-time device preparation: disable the lock screen for the session, keep the screen on
|
||||
for the whole run, zero the animation scales for deterministic UI tests, and issue the first
|
||||
wake / dismiss-keyguard nudge. All best-effort."""
|
||||
print("focus-gate: preparing device (wake + dismiss-keyguard + stay-awake + no-animations)")
|
||||
nudge_focus(adb, serial)
|
||||
_adb_quiet(adb, serial, "shell", "input", "keyevent", KEYCODE_MENU) # legacy #454 parity
|
||||
_adb_quiet(adb, serial, "shell", "locksettings", "set-disabled", "true")
|
||||
_adb_quiet(adb, serial, "shell", "svc", "power", "stayon", "true")
|
||||
_adb_quiet(adb, serial, "shell", "settings", "put", "system",
|
||||
"screen_off_timeout", SCREEN_OFF_TIMEOUT_MS)
|
||||
for scale in ("window_animation_scale", "transition_animation_scale",
|
||||
"animator_duration_scale"):
|
||||
_adb_quiet(adb, serial, "shell", "settings", "put", "global", scale, "0.0")
|
||||
|
||||
|
||||
def probe(adb: str, serial: str | None) -> Readiness:
|
||||
power_out = _adb_capture(adb, serial, "shell", "dumpsys", "power")
|
||||
window_out = _adb_capture(adb, serial, "shell", "dumpsys", "window")
|
||||
return evaluate_readiness(power_out, window_out)
|
||||
|
||||
|
||||
def wait_for_focus(adb: str, serial: str | None, timeout: int, label: str) -> Readiness:
|
||||
"""Prepare the device, then poll (re-nudging each iteration) until it is interactive with a
|
||||
focused window, or ``timeout`` seconds elapse. Returns the final Readiness (SOFT gate: the
|
||||
caller proceeds regardless -- see the module docstring)."""
|
||||
tag = f" [{label}]" if label else ""
|
||||
prepare_device(adb, serial)
|
||||
deadline = time.monotonic() + timeout
|
||||
last = probe(adb, serial)
|
||||
attempt = 0
|
||||
while True:
|
||||
if last.ready:
|
||||
elapsed = timeout - max(0, int(deadline - time.monotonic()))
|
||||
print(f"focus-gate{tag}: READY after ~{elapsed}s -- {last.summary}")
|
||||
return last
|
||||
if time.monotonic() >= deadline:
|
||||
print(f"::warning::focus-gate{tag}: window focus NOT confirmed within {timeout}s "
|
||||
f"-- proceeding anyway -- {last.summary}")
|
||||
return last
|
||||
attempt += 1
|
||||
if attempt % 5 == 0:
|
||||
print(f"focus-gate{tag}: waiting for window focus -- {last.summary}")
|
||||
nudge_focus(adb, serial)
|
||||
time.sleep(POLL_INTERVAL_S)
|
||||
last = probe(adb, serial)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="emulator_focus_gate.py",
|
||||
description=(
|
||||
"Force a booted emulator to grant the app window focus (wake + dismiss-keyguard + "
|
||||
"stay-awake + no-animations) and gate on that state before an instrumented UI "
|
||||
"suite runs. Shared by CI's e2e / e2e-preview jobs and the local preflight runners "
|
||||
"(issue #468)."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--serial", default=None,
|
||||
help="adb device serial (default: the single attached device).")
|
||||
parser.add_argument("--adb", default=None,
|
||||
help="Path to adb (default: resolve from PATH). For callers that resolve "
|
||||
"adb from the SDK rather than PATH (e.g. api37_e2e.py).")
|
||||
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_S,
|
||||
help=f"Max seconds to wait for window focus (default {DEFAULT_TIMEOUT_S}).")
|
||||
parser.add_argument("--label", default="",
|
||||
help="Label for log lines (e.g. an API level), for multi-leg runs.")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
adb = args.adb or shutil.which("adb")
|
||||
if not adb:
|
||||
# Non-fatal by contract: never turn a missing-tool hiccup into a red leg. The suite that
|
||||
# follows will surface a genuinely broken device.
|
||||
print("::warning::focus-gate: adb not on PATH -- skipping focus preparation/gate")
|
||||
return 0
|
||||
|
||||
wait_for_focus(adb, args.serial, args.timeout, args.label)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Hardened Android SDK setup for CI (issue #389).
|
||||
|
||||
The dominant merge-blocking flake was the **Set up Android SDK** step
|
||||
(`android-actions/setup-android`) dying *before* the emulator ever starts:
|
||||
|
||||
Wrong version in preinstalled sdkmanager
|
||||
Warning: ... preparing SDK package Android Emulator: Error reading Zip
|
||||
content from a SeekableByteChannel.
|
||||
Error: The process '.../sdkmanager' failed with exit code 1
|
||||
|
||||
Two root causes, both a corrupt/truncated download that a bare `sdkmanager`
|
||||
turns into an un-retried exit 1:
|
||||
|
||||
* the action's own **unverified** cmdline-tools re-download (its default
|
||||
cmdline-tools version rarely matches the runner image's preinstalled one, so
|
||||
it logs "Wrong version in preinstalled sdkmanager" and re-fetches with *no*
|
||||
checksum), and
|
||||
* the action's default ``packages: tools platform-tools`` install (the "SDK
|
||||
Tools" corrupt zip seen on a #388 preview shard) plus the emulator/platform
|
||||
package installs.
|
||||
|
||||
This module hardens both with **verify -> reject -> retry**, never trusting
|
||||
sdkmanager's exit code alone:
|
||||
|
||||
``bootstrap`` Download the *pinned* Android command-line tools zip, verify it
|
||||
against a pinned size + SHA-256, and install it to
|
||||
``$ANDROID_SDK_ROOT/cmdline-tools/<rev>`` -- the exact path
|
||||
setup-android probes first, so the action reuses our verified
|
||||
tree and never does its own unverified "Wrong version"
|
||||
re-download. A size/hash mismatch (corrupt OR wrong version)
|
||||
=> delete the bad zip + any half-extracted dir => re-download
|
||||
clean. Only a verified tree is ever left in place, so the
|
||||
success-gated cache can never bake in a corrupt SDK.
|
||||
|
||||
``install`` Run ``sdkmanager --install <packages>`` with retry + backoff.
|
||||
"Error reading Zip content from a SeekableByteChannel" is a
|
||||
corrupt package zip, so on failure each requested package's dir
|
||||
(and sdkmanager's temp/intermediate dirs) is PURGED before the
|
||||
retry -- forcing a fresh re-download instead of a re-read of the
|
||||
corrupt file.
|
||||
|
||||
stdlib only (urllib/hashlib/zipfile/...), cross-platform, per the repo's "prefer
|
||||
Python for dev/CI-helper scripts" rule. The pure helpers are unit-tested in
|
||||
``test_setup_android_sdk.py`` (run by the ``traffic-control-tests`` job); the
|
||||
full download/install path is validated by CI itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
# --- Pinned Android command-line tools (revision 20.0) --------------------
|
||||
# android-actions/setup-android v4.0.1 defaults to this same build (its
|
||||
# getVersionShort() maps "14742923" -> "20.0"). We provision it OURSELVES,
|
||||
# integrity-checked, into the path the action looks for first
|
||||
# ($ANDROID_SDK_ROOT/cmdline-tools/20.0), so the action finds it, skips its own
|
||||
# unverified download, and never prints "Wrong version in preinstalled
|
||||
# sdkmanager".
|
||||
#
|
||||
# CLT_SIZE + the SHA-1 are Google's published values for this immutable,
|
||||
# build-numbered zip (repository2-3.xml). CLT_SHA256 was computed locally from
|
||||
# bytes that matched BOTH of Google's published values, so it is an authoritative
|
||||
# integrity pin. A build-numbered URL is immutable, so these never drift; bumping
|
||||
# the tools means bumping all four constants together.
|
||||
CLT_VERSION_LONG = "14742923"
|
||||
CLT_VERSION_SHORT = "20.0"
|
||||
CLT_URL = (
|
||||
"https://dl.google.com/android/repository/"
|
||||
f"commandlinetools-linux-{CLT_VERSION_LONG}_latest.zip"
|
||||
)
|
||||
CLT_SIZE = 172789259
|
||||
CLT_SHA256 = "04453066b540409d975c676d781da1477479dde3761310f1a7eb92a1dfb15af7"
|
||||
|
||||
# Total tries (1 initial + retries). Backoff is linear: 10s, 20s, 30s ...
|
||||
MAX_ATTEMPTS = 4
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def warn(msg: str) -> None:
|
||||
print(f"::warning::{msg}", flush=True)
|
||||
|
||||
|
||||
def error(msg: str) -> None:
|
||||
print(f"::error::{msg}", flush=True)
|
||||
|
||||
|
||||
def backoff_seconds(attempt: int) -> int:
|
||||
"""Linear backoff before the next attempt: 10s after attempt 1, 20s after 2..."""
|
||||
return 10 * attempt
|
||||
|
||||
|
||||
def sdk_root() -> str:
|
||||
"""The Android SDK root. GitHub-hosted runners preset ANDROID_SDK_ROOT /
|
||||
ANDROID_HOME to /usr/local/lib/android/sdk; fall back to the SDK's default."""
|
||||
root = os.environ.get("ANDROID_SDK_ROOT") or os.environ.get("ANDROID_HOME")
|
||||
if not root:
|
||||
root = os.path.join(os.path.expanduser("~"), ".android", "sdk")
|
||||
return root
|
||||
|
||||
|
||||
def sha256_of(path: str) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def verify_download(path, expected_size, expected_sha256):
|
||||
"""(ok, detail) for a downloaded file: size first (cheap), then SHA-256.
|
||||
A mismatch means a corrupt/truncated download OR the wrong version -- both
|
||||
must be rejected and re-fetched."""
|
||||
if not os.path.exists(path):
|
||||
return False, "download missing"
|
||||
actual_size = os.path.getsize(path)
|
||||
if actual_size != expected_size:
|
||||
return False, f"size {actual_size} != expected {expected_size}"
|
||||
actual_sha = sha256_of(path)
|
||||
if actual_sha != expected_sha256:
|
||||
return False, f"sha256 {actual_sha} != expected {expected_sha256}"
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def package_dir(root: str, package: str) -> str:
|
||||
"""On-disk dir for an sdkmanager package id. sdkmanager lays packages out by
|
||||
turning the ';' separators into path separators, e.g.
|
||||
'platforms;android-37.0' -> <root>/platforms/android-37.0, so this is exactly
|
||||
the tree to purge to force a corrupt package to re-download."""
|
||||
return os.path.join(root, *package.split(";"))
|
||||
|
||||
|
||||
def _rm(path: str) -> None:
|
||||
"""Best-effort recursive delete of a file or dir (reject a bad download)."""
|
||||
if os.path.islink(path) or os.path.isfile(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
elif os.path.isdir(path):
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
|
||||
def _extract_preserving_perms(zip_path: str, target_dir: str) -> None:
|
||||
"""Extract a zip, restoring the unix permission bits stored in each entry's
|
||||
external attributes. ZipFile.extractall drops the executable bit, which would
|
||||
leave bin/sdkmanager non-executable and break the action's `sdkmanager
|
||||
--licenses`; Google's zip is unix-built, so external_attr carries the +x."""
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
for info in zf.infolist():
|
||||
extracted = zf.extract(info, target_dir)
|
||||
mode = (info.external_attr >> 16) & 0o7777
|
||||
if mode:
|
||||
os.chmod(extracted, mode)
|
||||
|
||||
|
||||
class _RejectAndRetry(Exception):
|
||||
"""Internal signal: discard this attempt's download and retry from scratch."""
|
||||
|
||||
|
||||
def bootstrap() -> int:
|
||||
"""Ensure $ANDROID_SDK_ROOT/cmdline-tools/<rev> is a verified install."""
|
||||
root = sdk_root()
|
||||
dest = os.path.join(root, "cmdline-tools", CLT_VERSION_SHORT)
|
||||
sdkmanager = os.path.join(dest, "bin", "sdkmanager")
|
||||
if os.path.exists(sdkmanager):
|
||||
# Cache hit (or already provisioned): the cache is populated only after a
|
||||
# passing integrity check, so a present tree is trusted -> no re-download.
|
||||
log(f"cmdline-tools {CLT_VERSION_SHORT} already present at {dest} "
|
||||
"(cache hit) -- skipping verified download")
|
||||
return 0
|
||||
|
||||
tools_parent = os.path.join(root, "cmdline-tools")
|
||||
os.makedirs(tools_parent, exist_ok=True)
|
||||
for attempt in range(1, MAX_ATTEMPTS + 1):
|
||||
log(f"::group::Download + verify cmdline-tools {CLT_VERSION_SHORT} "
|
||||
f"(attempt {attempt}/{MAX_ATTEMPTS})")
|
||||
tmp_zip = os.path.join(tempfile.gettempdir(), f"clt-{CLT_VERSION_LONG}.zip")
|
||||
# Extract on the SAME filesystem as `dest` so the final move is an atomic
|
||||
# rename that preserves the restored +x bit on bin/sdkmanager.
|
||||
tmp_extract = tempfile.mkdtemp(prefix=".clt-extract-", dir=tools_parent)
|
||||
_rm(tmp_zip)
|
||||
try:
|
||||
log(f"Downloading {CLT_URL}")
|
||||
urllib.request.urlretrieve(CLT_URL, tmp_zip) # noqa: S310 (pinned https)
|
||||
ok, detail = verify_download(tmp_zip, CLT_SIZE, CLT_SHA256)
|
||||
if not ok:
|
||||
warn(f"cmdline-tools integrity check failed: {detail} -- "
|
||||
"rejecting the bad download and retrying clean")
|
||||
raise _RejectAndRetry()
|
||||
log(f"Integrity OK (size {CLT_SIZE}, sha256 {CLT_SHA256})")
|
||||
_extract_preserving_perms(tmp_zip, tmp_extract)
|
||||
unpacked = os.path.join(tmp_extract, "cmdline-tools")
|
||||
if not os.path.isdir(unpacked):
|
||||
warn("extracted zip has no top-level cmdline-tools/ dir -- retrying")
|
||||
raise _RejectAndRetry()
|
||||
_rm(dest) # drop any half-extracted leftover before moving the good tree
|
||||
shutil.move(unpacked, dest)
|
||||
# Mirror the action: touch repositories.cfg so sdkmanager is happy.
|
||||
open(os.path.join(root, "repositories.cfg"), "a", encoding="utf-8").close()
|
||||
if os.path.exists(sdkmanager):
|
||||
log(f"Installed verified cmdline-tools to {dest}")
|
||||
return 0
|
||||
warn("sdkmanager missing after extract -- retrying")
|
||||
except _RejectAndRetry:
|
||||
pass
|
||||
except Exception as exc: # noqa: BLE001 - any transient error is retryable
|
||||
warn(f"cmdline-tools bootstrap attempt {attempt} failed: {exc}")
|
||||
finally:
|
||||
_rm(tmp_zip)
|
||||
_rm(tmp_extract)
|
||||
log("::endgroup::")
|
||||
if attempt < MAX_ATTEMPTS:
|
||||
time.sleep(backoff_seconds(attempt))
|
||||
error(f"Failed to provision verified cmdline-tools after {MAX_ATTEMPTS} attempts")
|
||||
return 1
|
||||
|
||||
|
||||
def find_sdkmanager(root: str):
|
||||
"""Locate sdkmanager: our pinned rev first, then the action's `latest`, then PATH."""
|
||||
candidates = [
|
||||
os.path.join(root, "cmdline-tools", CLT_VERSION_SHORT, "bin", "sdkmanager"),
|
||||
os.path.join(root, "cmdline-tools", "latest", "bin", "sdkmanager"),
|
||||
]
|
||||
for candidate in candidates:
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
return shutil.which("sdkmanager")
|
||||
|
||||
|
||||
def install(packages) -> int:
|
||||
"""`sdkmanager --install <packages>` with retry + purge-on-corrupt-zip."""
|
||||
root = sdk_root()
|
||||
sdkmanager = find_sdkmanager(root)
|
||||
if not sdkmanager:
|
||||
error("sdkmanager not found -- run the cmdline-tools bootstrap step first")
|
||||
return 1
|
||||
# Feed 'y' repeatedly in case any license needs accepting (setup-android's
|
||||
# --licenses runs first, but this keeps the step self-contained).
|
||||
accept = ("y\n" * 32).encode()
|
||||
for attempt in range(1, MAX_ATTEMPTS + 1):
|
||||
log(f"::group::sdkmanager --install {' '.join(packages)} "
|
||||
f"(attempt {attempt}/{MAX_ATTEMPTS})")
|
||||
result = subprocess.run([sdkmanager, "--install", *packages], input=accept)
|
||||
log("::endgroup::")
|
||||
if result.returncode == 0:
|
||||
log(f"Installed SDK packages: {' '.join(packages)}")
|
||||
return 0
|
||||
warn(f"sdkmanager attempt {attempt} failed (exit {result.returncode}) -- "
|
||||
"purging partial/corrupt packages before retry")
|
||||
# REJECT: a corrupt package zip must be re-downloaded, not re-read. Purge
|
||||
# each requested package's dir + sdkmanager's temp/intermediate dirs so
|
||||
# the retry starts clean.
|
||||
for pkg in packages:
|
||||
_rm(package_dir(root, pkg))
|
||||
_rm(os.path.join(root, ".temp"))
|
||||
_rm(os.path.join(root, ".downloadIntermediates"))
|
||||
if attempt < MAX_ATTEMPTS:
|
||||
time.sleep(backoff_seconds(attempt))
|
||||
error(f"sdkmanager failed to install {list(packages)} after {MAX_ATTEMPTS} attempts")
|
||||
log("--- sdkmanager --list_installed ---")
|
||||
subprocess.run([sdkmanager, "--list_installed"])
|
||||
return 1
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Hardened Android SDK setup for CI (issue #389)."
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
sub.add_parser(
|
||||
"bootstrap",
|
||||
help="Download + SHA-256-verify the pinned Android command-line tools.",
|
||||
)
|
||||
installer = sub.add_parser(
|
||||
"install",
|
||||
help="sdkmanager --install with retry + purge-on-corrupt-zip.",
|
||||
)
|
||||
installer.add_argument("packages", nargs="+", help="sdkmanager package ids")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
if args.command == "bootstrap":
|
||||
return bootstrap()
|
||||
if args.command == "install":
|
||||
return install(args.packages)
|
||||
return 2 # pragma: no cover - argparse requires a subcommand
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,111 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Unit tests for the pure readiness parser of emulator_focus_gate.py (no adb, no emulator).
|
||||
|
||||
Covers the decision core that decides whether a booted emulator is ready for a focus-dependent
|
||||
instrumented UI suite (issue #468): interactive (``mWakefulness=Awake``) AND a real window holds
|
||||
input focus (``mCurrentFocus`` is a non-null ``Window{...}``). The window-focus flake this guards
|
||||
against is exactly the "awake but mCurrentFocus=null" state, so that case must read NOT ready."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
import emulator_focus_gate as gate
|
||||
|
||||
# A ``dumpsys power`` where the display is interactive vs. asleep.
|
||||
POWER_AWAKE = "Power Manager State:\n mWakefulness=Awake\n mWakefulnessChanging=false\n"
|
||||
POWER_ASLEEP = "Power Manager State:\n mWakefulness=Asleep\n mWakefulnessChanging=false\n"
|
||||
|
||||
# ``dumpsys window`` with a focused app window (the healthy state RootViewPicker needs)...
|
||||
WINDOW_FOCUSED = (
|
||||
" mCurrentFocus=Window{23e192a u0 org.libremail.app/org.libremail.MainActivity}\n"
|
||||
" mFocusedApp=ActivityRecord{a1 u0 org.libremail.app/.MainActivity t9}\n"
|
||||
" mDreamingLockscreen=false\n"
|
||||
)
|
||||
# ...and the flake state: interactive-parse aside, NO window holds focus.
|
||||
WINDOW_NO_FOCUS = " mCurrentFocus=null\n mFocusedApp=null\n mDreamingLockscreen=true\n"
|
||||
|
||||
|
||||
class AwakeParsingTests(unittest.TestCase):
|
||||
def test_mwakefulness_awake(self) -> None:
|
||||
self.assertTrue(gate._is_awake(POWER_AWAKE))
|
||||
|
||||
def test_mwakefulness_asleep(self) -> None:
|
||||
self.assertFalse(gate._is_awake(POWER_ASLEEP))
|
||||
|
||||
def test_display_power_state_on_fallback(self) -> None:
|
||||
self.assertTrue(gate._is_awake("Display Power: state=ON"))
|
||||
|
||||
def test_minteractive_fallback(self) -> None:
|
||||
self.assertTrue(gate._is_awake("mInteractive=true"))
|
||||
|
||||
def test_empty_is_not_awake(self) -> None:
|
||||
self.assertFalse(gate._is_awake(""))
|
||||
|
||||
|
||||
class FocusParsingTests(unittest.TestCase):
|
||||
def test_non_null_current_focus(self) -> None:
|
||||
state, value = gate._focus(WINDOW_FOCUSED)
|
||||
self.assertEqual(state, "focused")
|
||||
self.assertTrue(value.startswith("Window{"))
|
||||
|
||||
def test_null_current_focus(self) -> None:
|
||||
self.assertEqual(gate._focus(WINDOW_NO_FOCUS), ("unfocused", "null"))
|
||||
|
||||
def test_focused_window_fallback_field(self) -> None:
|
||||
state, value = gate._focus("mFocusedWindow=Window{deadbeef u0 launcher}\n")
|
||||
self.assertEqual(state, "focused")
|
||||
self.assertEqual(value, "Window{deadbeef")
|
||||
|
||||
def test_absent_focus_field_is_unknown(self) -> None:
|
||||
self.assertEqual(gate._focus("no focus fields here"), ("unknown", ""))
|
||||
|
||||
|
||||
class KeyguardParsingTests(unittest.TestCase):
|
||||
def test_showing(self) -> None:
|
||||
self.assertEqual(gate._keyguard("mDreamingLockscreen=true"), "showing")
|
||||
|
||||
def test_not_showing(self) -> None:
|
||||
self.assertEqual(gate._keyguard("isKeyguardShowing=false"), "not_showing")
|
||||
|
||||
def test_unknown(self) -> None:
|
||||
self.assertEqual(gate._keyguard("nothing relevant"), "unknown")
|
||||
|
||||
|
||||
class EvaluateReadinessTests(unittest.TestCase):
|
||||
def test_awake_and_focused_is_ready(self) -> None:
|
||||
result = gate.evaluate_readiness(POWER_AWAKE, WINDOW_FOCUSED)
|
||||
self.assertTrue(result.ready)
|
||||
self.assertTrue(result.awake)
|
||||
self.assertEqual(result.focus_state, "focused")
|
||||
|
||||
def test_the_flake_awake_but_no_focus_is_not_ready(self) -> None:
|
||||
# The exact issue #468 signature: display parses/awake but no window has focus.
|
||||
result = gate.evaluate_readiness(POWER_AWAKE, WINDOW_NO_FOCUS)
|
||||
self.assertFalse(result.ready)
|
||||
|
||||
def test_asleep_even_with_focus_is_not_ready(self) -> None:
|
||||
result = gate.evaluate_readiness(POWER_ASLEEP, WINDOW_FOCUSED)
|
||||
self.assertFalse(result.ready)
|
||||
|
||||
def test_unknown_focus_but_awake_and_keyguard_gone_is_ready(self) -> None:
|
||||
# Fallback so a dump format without mCurrentFocus can't hang the gate forever.
|
||||
result = gate.evaluate_readiness(POWER_AWAKE, "mDreamingLockscreen=false")
|
||||
self.assertTrue(result.ready)
|
||||
|
||||
def test_unknown_focus_and_keyguard_showing_is_not_ready(self) -> None:
|
||||
result = gate.evaluate_readiness(POWER_AWAKE, "mDreamingLockscreen=true")
|
||||
self.assertFalse(result.ready)
|
||||
|
||||
def test_unknown_focus_and_keyguard_unknown_is_not_ready(self) -> None:
|
||||
result = gate.evaluate_readiness(POWER_AWAKE, "")
|
||||
self.assertFalse(result.ready)
|
||||
|
||||
def test_summary_is_human_readable(self) -> None:
|
||||
summary = gate.evaluate_readiness(POWER_AWAKE, WINDOW_FOCUSED).summary
|
||||
self.assertIn("awake=True", summary)
|
||||
self.assertIn("focus=focused", summary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Unit tests for the pure helpers of setup_android_sdk.py (no network, no SDK).
|
||||
|
||||
Covers the bits whose correctness is load-bearing for the hardening in #389:
|
||||
the package-id -> purge-path mapping (a wrong mapping would purge the wrong dir),
|
||||
the size/SHA-256 integrity gate (verify -> reject), the pinned-constant
|
||||
self-consistency, the backoff schedule, sdkmanager discovery, and that extraction
|
||||
restores the executable bit that sdkmanager needs. The full download/install path
|
||||
is exercised by CI itself."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
|
||||
import setup_android_sdk as sdk
|
||||
|
||||
|
||||
class PackageDirTests(unittest.TestCase):
|
||||
def test_semicolon_ids_map_to_nested_dirs(self):
|
||||
root = os.path.join("opt", "sdk")
|
||||
self.assertEqual(
|
||||
sdk.package_dir(root, "platforms;android-37.0"),
|
||||
os.path.join(root, "platforms", "android-37.0"),
|
||||
)
|
||||
self.assertEqual(
|
||||
sdk.package_dir(root, "build-tools;37.0.0"),
|
||||
os.path.join(root, "build-tools", "37.0.0"),
|
||||
)
|
||||
self.assertEqual(
|
||||
sdk.package_dir(root, "system-images;android-37.0;google_apis_ps16k;x86_64"),
|
||||
os.path.join(root, "system-images", "android-37.0", "google_apis_ps16k", "x86_64"),
|
||||
)
|
||||
# The matrix e2e legs (#443) pre-install the google_apis/x86_64 image for their API level
|
||||
# through this same installer, so a corrupt emulator/system-image zip is purged from the
|
||||
# right dir on retry — assert that (non-ps16k) id maps correctly too.
|
||||
self.assertEqual(
|
||||
sdk.package_dir(root, "system-images;android-33;google_apis;x86_64"),
|
||||
os.path.join(root, "system-images", "android-33", "google_apis", "x86_64"),
|
||||
)
|
||||
|
||||
def test_flat_ids_map_to_single_dir(self):
|
||||
root = os.path.join("opt", "sdk")
|
||||
self.assertEqual(sdk.package_dir(root, "emulator"), os.path.join(root, "emulator"))
|
||||
self.assertEqual(
|
||||
sdk.package_dir(root, "platform-tools"), os.path.join(root, "platform-tools")
|
||||
)
|
||||
|
||||
def test_purge_target_stays_under_root(self):
|
||||
# The purge path must never escape the SDK root (no absolute/`..` package ids).
|
||||
root = os.path.abspath(os.path.join("opt", "sdk"))
|
||||
target = os.path.abspath(sdk.package_dir(root, "platforms;android-37.0"))
|
||||
self.assertTrue(target.startswith(root + os.sep))
|
||||
|
||||
|
||||
class VerifyDownloadTests(unittest.TestCase):
|
||||
def _write(self, data: bytes) -> str:
|
||||
fd, path = tempfile.mkstemp()
|
||||
with os.fdopen(fd, "wb") as fh:
|
||||
fh.write(data)
|
||||
self.addCleanup(lambda: os.path.exists(path) and os.remove(path))
|
||||
return path
|
||||
|
||||
def test_accepts_matching_size_and_hash(self):
|
||||
data = b"correct-cmdline-tools-bytes"
|
||||
path = self._write(data)
|
||||
ok, detail = sdk.verify_download(path, len(data), hashlib.sha256(data).hexdigest())
|
||||
self.assertTrue(ok, detail)
|
||||
self.assertEqual(detail, "ok")
|
||||
|
||||
def test_rejects_wrong_size_before_hashing(self):
|
||||
data = b"truncated"
|
||||
path = self._write(data)
|
||||
ok, detail = sdk.verify_download(path, len(data) + 1, hashlib.sha256(data).hexdigest())
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("size", detail)
|
||||
|
||||
def test_rejects_corrupt_bytes_with_right_size(self):
|
||||
good = b"aaaaaaaa"
|
||||
corrupt = b"aaaaaaab" # same length, different content (silent corruption)
|
||||
path = self._write(corrupt)
|
||||
ok, detail = sdk.verify_download(path, len(good), hashlib.sha256(good).hexdigest())
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("sha256", detail)
|
||||
|
||||
def test_rejects_missing_file(self):
|
||||
ok, detail = sdk.verify_download(
|
||||
os.path.join(tempfile.gettempdir(), "does-not-exist-clt.zip"), 1, "0" * 64
|
||||
)
|
||||
self.assertFalse(ok)
|
||||
|
||||
|
||||
class PinnedConstantsTests(unittest.TestCase):
|
||||
def test_url_embeds_the_pinned_build_number(self):
|
||||
self.assertIn(sdk.CLT_VERSION_LONG, sdk.CLT_URL)
|
||||
self.assertTrue(sdk.CLT_URL.startswith("https://"))
|
||||
self.assertTrue(sdk.CLT_URL.endswith("_latest.zip"))
|
||||
|
||||
def test_sha256_is_a_full_hex_digest(self):
|
||||
self.assertEqual(len(sdk.CLT_SHA256), 64)
|
||||
int(sdk.CLT_SHA256, 16) # raises if not hex
|
||||
self.assertEqual(sdk.CLT_SHA256, sdk.CLT_SHA256.lower())
|
||||
|
||||
def test_size_is_positive(self):
|
||||
self.assertGreater(sdk.CLT_SIZE, 0)
|
||||
|
||||
|
||||
class BackoffTests(unittest.TestCase):
|
||||
def test_backoff_is_linear_and_increasing(self):
|
||||
seq = [sdk.backoff_seconds(a) for a in range(1, sdk.MAX_ATTEMPTS + 1)]
|
||||
self.assertEqual(seq, [10, 20, 30, 40][: sdk.MAX_ATTEMPTS])
|
||||
self.assertEqual(seq, sorted(seq))
|
||||
|
||||
|
||||
class SdkRootTests(unittest.TestCase):
|
||||
def test_prefers_android_sdk_root_over_home(self):
|
||||
with _env(ANDROID_SDK_ROOT="/a/sdk-root", ANDROID_HOME="/b/home"):
|
||||
self.assertEqual(sdk.sdk_root(), "/a/sdk-root")
|
||||
|
||||
def test_falls_back_to_android_home(self):
|
||||
with _env(ANDROID_SDK_ROOT=None, ANDROID_HOME="/b/home"):
|
||||
self.assertEqual(sdk.sdk_root(), "/b/home")
|
||||
|
||||
|
||||
class FindSdkManagerTests(unittest.TestCase):
|
||||
def test_prefers_pinned_revision_dir(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
pinned = os.path.join(root, "cmdline-tools", sdk.CLT_VERSION_SHORT, "bin")
|
||||
latest = os.path.join(root, "cmdline-tools", "latest", "bin")
|
||||
for d in (pinned, latest):
|
||||
os.makedirs(d)
|
||||
open(os.path.join(d, "sdkmanager"), "w").close()
|
||||
self.assertEqual(
|
||||
sdk.find_sdkmanager(root),
|
||||
os.path.join(pinned, "sdkmanager"),
|
||||
)
|
||||
|
||||
|
||||
class ExtractPermsTests(unittest.TestCase):
|
||||
@unittest.skipUnless(os.name == "posix", "unix exec bit only meaningful on POSIX")
|
||||
def test_executable_bit_is_restored(self):
|
||||
with tempfile.TemporaryDirectory() as work:
|
||||
zip_path = os.path.join(work, "clt.zip")
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
info = zipfile.ZipInfo("cmdline-tools/bin/sdkmanager")
|
||||
info.external_attr = 0o755 << 16 # -rwxr-xr-x, as Google's zip stores it
|
||||
zf.writestr(info, "#!/bin/sh\n")
|
||||
out = os.path.join(work, "out")
|
||||
sdk._extract_preserving_perms(zip_path, out)
|
||||
mode = os.stat(os.path.join(out, "cmdline-tools", "bin", "sdkmanager")).st_mode
|
||||
self.assertTrue(mode & stat.S_IXUSR, "sdkmanager must be executable after extract")
|
||||
|
||||
|
||||
class _env:
|
||||
"""Context manager to set/clear env vars for a test, restoring them after."""
|
||||
|
||||
def __init__(self, **values):
|
||||
self._values = values
|
||||
self._saved = {}
|
||||
|
||||
def __enter__(self):
|
||||
for key, value in self._values.items():
|
||||
self._saved[key] = os.environ.get(key)
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
for key, previous in self._saved.items():
|
||||
if previous is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = previous
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,509 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Unit tests for the pure decision core of traffic_control.py (no network).
|
||||
|
||||
Covers: priority resolution (P-label / broken / draft / default P5), PASS 1
|
||||
preemption (P0 reclaims all strictly-lower; ANY higher PR reclaims a broken/draft
|
||||
lower run; P1-P9 never bump a *normal* lower run; self / main / equal-or-higher
|
||||
never cancelled), PASS 2 hold-back (yield to strictly-higher with an active run;
|
||||
same-level running-first then oldest-first), and a few end-to-end decision
|
||||
scenarios.
|
||||
|
||||
Also covers the --mode trigger scheduler core (issue #349): head-SHA run
|
||||
classification (absent/cancelled => needy; success/failure => not needy),
|
||||
select_triggers (priority order, oldest-first fairness, inflight cap, fork skip,
|
||||
P0 bypasses-cap-and-preempts), and a liveness/anti-starvation simulation proving
|
||||
every eligible PR is triggered within a bounded number of passes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
import traffic_control as tc
|
||||
from traffic_control import PullRequest
|
||||
|
||||
|
||||
def pr(number, labels=(), *, draft=False, created_at="", status=tc.NONE, run_ids=()):
|
||||
"""Terse PullRequest builder for tests."""
|
||||
return PullRequest(
|
||||
number=number,
|
||||
labels=tuple(labels),
|
||||
is_draft=draft,
|
||||
created_at=created_at,
|
||||
run_status=status,
|
||||
run_ids=tuple(run_ids),
|
||||
)
|
||||
|
||||
|
||||
class EffectivePriorityTests(unittest.TestCase):
|
||||
def test_no_labels_defaults_to_p5(self):
|
||||
self.assertEqual(tc.effective_priority(pr(1)), 5)
|
||||
|
||||
def test_non_priority_labels_ignored_default_p5(self):
|
||||
self.assertEqual(tc.effective_priority(pr(1, ["bug", "enhancement"])), 5)
|
||||
|
||||
def test_single_p_label(self):
|
||||
self.assertEqual(tc.effective_priority(pr(1, ["P3"])), 3)
|
||||
self.assertEqual(tc.effective_priority(pr(1, ["P0"])), 0)
|
||||
|
||||
def test_lowest_numbered_p_label_wins(self):
|
||||
self.assertEqual(tc.effective_priority(pr(1, ["P4", "P1", "P7"])), 1)
|
||||
|
||||
def test_broken_is_bottom_p10_overriding_p0(self):
|
||||
self.assertEqual(tc.effective_priority(pr(1, ["broken", "P0"])), 10)
|
||||
|
||||
def test_draft_is_bottom_p10_overriding_p0(self):
|
||||
self.assertEqual(tc.effective_priority(pr(1, ["P0"], draft=True)), 10)
|
||||
|
||||
def test_draft_and_broken_still_p10(self):
|
||||
self.assertEqual(tc.effective_priority(pr(1, ["broken"], draft=True)), 10)
|
||||
|
||||
def test_double_digit_pseudo_label_is_not_a_priority(self):
|
||||
# Only P0-P9 count (regex ^P[0-9]$); "P10" is not a valid priority label.
|
||||
self.assertEqual(tc.effective_priority(pr(1, ["P10"])), 5)
|
||||
|
||||
def test_priority_label_text(self):
|
||||
self.assertEqual(tc.priority_label(0), "P0")
|
||||
self.assertEqual(tc.priority_label(5), "P5")
|
||||
self.assertIn("bottom", tc.priority_label(10))
|
||||
|
||||
|
||||
class RunsToCancelTests(unittest.TestCase):
|
||||
def test_non_p0_self_does_not_bump_normal_lower_run(self):
|
||||
# P1-P9 never preempt a *normal* strictly-lower run — they yield instead.
|
||||
me = pr(1, ["P1"])
|
||||
others = [pr(2, ["P5"], status=tc.RUNNING, run_ids=[200])]
|
||||
self.assertEqual(tc.runs_to_cancel(me, [me, *others]), [])
|
||||
|
||||
def test_non_p0_self_reclaims_broken_lower_run(self):
|
||||
# Any higher-priority PR (not just P0) may reclaim a broken target's runner.
|
||||
me = pr(1, ["P3"])
|
||||
broken = pr(2, ["broken"], status=tc.RUNNING, run_ids=[200])
|
||||
self.assertEqual(tc.runs_to_cancel(me, [me, broken]), [200])
|
||||
|
||||
def test_non_p0_self_reclaims_draft_lower_run(self):
|
||||
# A draft is not merge-ready — its run is likewise reclaimable by any higher PR.
|
||||
me = pr(1, ["P3"])
|
||||
draft = pr(2, [], draft=True, status=tc.QUEUED, run_ids=[200])
|
||||
self.assertEqual(tc.runs_to_cancel(me, [me, draft]), [200])
|
||||
|
||||
def test_broken_self_does_not_cancel_equal_broken(self):
|
||||
# Both effective P10 — the equal-or-higher invariant still forbids cancelling.
|
||||
me = pr(1, ["broken"])
|
||||
peer = pr(2, ["broken"], status=tc.RUNNING, run_ids=[200])
|
||||
self.assertEqual(tc.runs_to_cancel(me, [me, peer]), [])
|
||||
|
||||
def test_bottom_self_preempts_nothing(self):
|
||||
# A broken/draft PR (P10) is the bottom: nothing is strictly-lower, so it
|
||||
# cancels neither a higher (P5) nor an equal (P10) run.
|
||||
me = pr(1, [], draft=True) # P10
|
||||
prs = [
|
||||
me,
|
||||
pr(2, ["P5"], status=tc.RUNNING, run_ids=[200]), # higher
|
||||
pr(3, ["broken"], status=tc.RUNNING, run_ids=[300]), # equal P10
|
||||
]
|
||||
self.assertEqual(tc.runs_to_cancel(me, prs), [])
|
||||
|
||||
def test_p0_cancels_strictly_lower_active_runs(self):
|
||||
me = pr(1, ["P0"])
|
||||
low = pr(2, ["P5"], status=tc.RUNNING, run_ids=[200])
|
||||
queued = pr(3, ["P9"], status=tc.QUEUED, run_ids=[300])
|
||||
self.assertEqual(
|
||||
sorted(tc.runs_to_cancel(me, [me, low, queued])), [200, 300])
|
||||
|
||||
def test_p0_cancels_broken_and_draft_lower_runs(self):
|
||||
me = pr(1, ["P0"])
|
||||
broken = pr(2, ["broken"], status=tc.RUNNING, run_ids=[200])
|
||||
draft = pr(3, ["P2"], draft=True, status=tc.RUNNING, run_ids=[300])
|
||||
self.assertEqual(
|
||||
sorted(tc.runs_to_cancel(me, [me, broken, draft])), [200, 300])
|
||||
|
||||
def test_p0_never_cancels_equal_priority_p0(self):
|
||||
me = pr(1, ["P0"])
|
||||
peer = pr(2, ["P0"], status=tc.RUNNING, run_ids=[200])
|
||||
self.assertEqual(tc.runs_to_cancel(me, [me, peer]), [])
|
||||
|
||||
def test_p0_never_cancels_self(self):
|
||||
me = pr(1, ["P0"], status=tc.RUNNING, run_ids=[100])
|
||||
self.assertEqual(tc.runs_to_cancel(me, [me]), [])
|
||||
|
||||
def test_p0_excludes_own_run_id_defensively(self):
|
||||
me = pr(1, ["P0"], status=tc.RUNNING, run_ids=[100])
|
||||
# A lower PR that somehow reports our own run id must not be cancelled.
|
||||
low = pr(2, ["P5"], status=tc.RUNNING, run_ids=[100, 200])
|
||||
self.assertEqual(
|
||||
tc.runs_to_cancel(me, [me, low], self_run_id=100), [200])
|
||||
|
||||
def test_p0_skips_lower_with_no_active_run(self):
|
||||
me = pr(1, ["P0"])
|
||||
idle = pr(2, ["P5"], status=tc.NONE, run_ids=[])
|
||||
self.assertEqual(tc.runs_to_cancel(me, [me, idle]), [])
|
||||
|
||||
|
||||
class WaitBlockersTests(unittest.TestCase):
|
||||
def test_p0_never_waits(self):
|
||||
me = pr(1, ["P0"])
|
||||
higher = pr(2, ["P0"], status=tc.RUNNING) # nothing outranks P0 anyway
|
||||
self.assertEqual(tc.wait_blockers(me, [me, higher]), [])
|
||||
|
||||
def test_yields_to_strictly_higher_with_active_run(self):
|
||||
me = pr(2, ["P5"], status=tc.RUNNING)
|
||||
higher = pr(1, ["P2"], status=tc.RUNNING)
|
||||
blockers = tc.wait_blockers(me, [me, higher])
|
||||
self.assertEqual([b.number for b in blockers], [1])
|
||||
self.assertEqual(blockers[0].kind, "higher-priority")
|
||||
|
||||
def test_does_not_yield_to_higher_without_active_run(self):
|
||||
me = pr(2, ["P5"], status=tc.RUNNING)
|
||||
higher_idle = pr(1, ["P2"], status=tc.NONE)
|
||||
self.assertEqual(tc.wait_blockers(me, [me, higher_idle]), [])
|
||||
|
||||
def test_does_not_yield_to_lower_priority(self):
|
||||
me = pr(1, ["P2"], status=tc.RUNNING)
|
||||
lower = pr(2, ["P5"], status=tc.RUNNING)
|
||||
self.assertEqual(tc.wait_blockers(me, [me, lower]), [])
|
||||
|
||||
def test_same_level_oldest_running_proceeds(self):
|
||||
me = pr(1, ["P5"], created_at="2026-07-01T00:00:00Z", status=tc.RUNNING)
|
||||
newer = pr(2, ["P5"], created_at="2026-07-02T00:00:00Z", status=tc.RUNNING)
|
||||
self.assertEqual(tc.wait_blockers(me, [me, newer]), [])
|
||||
|
||||
def test_same_level_newer_running_yields_to_older(self):
|
||||
# Coordinator clarification: within a level, older createdAt goes first.
|
||||
older = pr(1, ["P5"], created_at="2026-07-01T00:00:00Z", status=tc.RUNNING)
|
||||
me = pr(2, ["P5"], created_at="2026-07-02T00:00:00Z", status=tc.RUNNING)
|
||||
blockers = tc.wait_blockers(me, [me, older])
|
||||
self.assertEqual([b.number for b in blockers], [1])
|
||||
self.assertEqual(blockers[0].kind, "same-level-ahead")
|
||||
|
||||
def test_same_level_running_first_beats_older_waiting(self):
|
||||
# An in-flight peer keeps its place; a not-yet-running OLDER peer does not
|
||||
# jump ahead of us while we are the one already running.
|
||||
me = pr(2, ["P5"], created_at="2026-07-02T00:00:00Z", status=tc.RUNNING)
|
||||
older_waiting = pr(1, ["P5"], created_at="2026-07-01T00:00:00Z", status=tc.NONE)
|
||||
self.assertEqual(tc.wait_blockers(me, [me, older_waiting]), [])
|
||||
|
||||
def test_same_level_waiting_orders_oldest_before_newer(self):
|
||||
# Neither running: strictly oldest-first among the waiting bucket.
|
||||
oldest = pr(1, ["P5"], created_at="2026-07-01T00:00:00Z", status=tc.NONE)
|
||||
middle = pr(2, ["P5"], created_at="2026-07-02T00:00:00Z", status=tc.NONE)
|
||||
me = pr(3, ["P5"], created_at="2026-07-03T00:00:00Z", status=tc.NONE)
|
||||
blockers = tc.wait_blockers(me, [oldest, middle, me])
|
||||
self.assertEqual([b.number for b in blockers], [1, 2])
|
||||
|
||||
def test_same_level_queued_counts_as_waiting_ordered_by_age(self):
|
||||
# A queued peer is "waiting to start", not in-flight: ordered purely by age.
|
||||
me = pr(1, ["P5"], created_at="2026-07-01T00:00:00Z", status=tc.NONE)
|
||||
newer_queued = pr(2, ["P5"], created_at="2026-07-02T00:00:00Z", status=tc.QUEUED)
|
||||
self.assertEqual(tc.wait_blockers(me, [me, newer_queued]), [])
|
||||
|
||||
def test_broken_self_yields_to_everyone_active(self):
|
||||
me = pr(1, ["broken"], status=tc.RUNNING) # effective P10
|
||||
normal = pr(2, ["P5"], status=tc.RUNNING)
|
||||
blockers = tc.wait_blockers(me, [me, normal])
|
||||
self.assertEqual([b.number for b in blockers], [2])
|
||||
self.assertEqual(blockers[0].kind, "higher-priority")
|
||||
|
||||
|
||||
class EndToEndDecisionTests(unittest.TestCase):
|
||||
def test_p0_emergency_cancels_lower_and_proceeds(self):
|
||||
me = pr(10, ["P0"], status=tc.RUNNING, run_ids=[1000])
|
||||
prs = [
|
||||
me,
|
||||
pr(11, ["P2"], status=tc.RUNNING, run_ids=[1100]),
|
||||
pr(12, ["P5"], status=tc.QUEUED, run_ids=[1200]),
|
||||
pr(13, ["P0"], status=tc.RUNNING, run_ids=[1300]), # equal — spared
|
||||
]
|
||||
dec = tc.decide(me, prs, self_run_id=1000)
|
||||
self.assertEqual(sorted(dec.cancel_run_ids), [1100, 1200])
|
||||
self.assertTrue(dec.proceed)
|
||||
|
||||
def test_p5_waits_behind_running_higher(self):
|
||||
me = pr(20, ["P5"], status=tc.RUNNING, run_ids=[2000])
|
||||
higher = pr(21, ["P2"], status=tc.RUNNING, run_ids=[2100])
|
||||
dec = tc.decide(me, [me, higher])
|
||||
self.assertEqual(dec.cancel_run_ids, ()) # not P0 — cancels nothing
|
||||
self.assertFalse(dec.proceed)
|
||||
self.assertEqual([b.number for b in dec.blockers], [21])
|
||||
|
||||
def test_lone_p5_proceeds(self):
|
||||
me = pr(30, ["P5"], status=tc.RUNNING, run_ids=[3000])
|
||||
dec = tc.decide(me, [me])
|
||||
self.assertEqual(dec.cancel_run_ids, ())
|
||||
self.assertTrue(dec.proceed)
|
||||
|
||||
def test_p5_reclaims_draft_then_waits_behind_higher(self):
|
||||
# A non-P0 PR can BOTH reclaim a broken/draft lower run (PASS 1) AND still
|
||||
# yield to a strictly-higher PR (PASS 2) in the same evaluation.
|
||||
me = pr(40, ["P5"], status=tc.RUNNING, run_ids=[4000])
|
||||
prs = [
|
||||
me,
|
||||
pr(41, ["P2"], status=tc.RUNNING, run_ids=[4100]), # higher — blocks
|
||||
pr(42, [], draft=True, status=tc.RUNNING, run_ids=[4200]), # draft — reclaimed
|
||||
]
|
||||
dec = tc.decide(me, prs)
|
||||
self.assertEqual(list(dec.cancel_run_ids), [4200])
|
||||
self.assertFalse(dec.proceed)
|
||||
self.assertEqual([b.number for b in dec.blockers], [41])
|
||||
|
||||
|
||||
class SnapshotParsingTests(unittest.TestCase):
|
||||
def test_from_json_label_objects_and_fields(self):
|
||||
obj = {
|
||||
"number": 7,
|
||||
"labels": [{"name": "P3"}, {"name": "bug"}],
|
||||
"isDraft": True,
|
||||
"createdAt": "2026-07-01T00:00:00Z",
|
||||
"runStatus": "running",
|
||||
"runIds": [42, 43],
|
||||
}
|
||||
p = PullRequest.from_json(obj)
|
||||
self.assertEqual(p.number, 7)
|
||||
self.assertEqual(p.labels, ("P3", "bug"))
|
||||
self.assertTrue(p.is_draft)
|
||||
self.assertEqual(p.run_status, tc.RUNNING)
|
||||
self.assertEqual(p.run_ids, (42, 43))
|
||||
self.assertEqual(tc.effective_priority(p), 10) # draft => bottom
|
||||
|
||||
def test_from_json_plain_string_labels_and_unknown_status(self):
|
||||
p = PullRequest.from_json(
|
||||
{"number": 8, "labels": ["P1"], "runStatus": "bogus"})
|
||||
self.assertEqual(p.labels, ("P1",))
|
||||
self.assertEqual(p.run_status, tc.NONE) # unknown -> none
|
||||
|
||||
def test_load_snapshot_roundtrip(self):
|
||||
text = json.dumps({
|
||||
"self": 2,
|
||||
"self_run_id": 222,
|
||||
"prs": [
|
||||
{"number": 1, "labels": ["P2"], "runStatus": "running",
|
||||
"runIds": [111]},
|
||||
{"number": 2, "labels": ["P5"], "runStatus": "running",
|
||||
"runIds": [222]},
|
||||
],
|
||||
})
|
||||
this_pr, all_prs, self_run_id = tc._load_snapshot(text)
|
||||
self.assertEqual(this_pr.number, 2)
|
||||
self.assertEqual(len(all_prs), 2)
|
||||
self.assertEqual(self_run_id, 222)
|
||||
|
||||
|
||||
# ── --mode trigger scheduler core (issue #349) ───────────────────────────────
|
||||
def npr(number, labels=("P5",), *, draft=False, created_at="", status=tc.NONE, run_ids=()):
|
||||
"""Terse builder defaulting to a P5 PR (for the trigger tests)."""
|
||||
return pr(number, labels, draft=draft, created_at=created_at, status=status,
|
||||
run_ids=run_ids)
|
||||
|
||||
|
||||
class ClassifyShaRunsTests(unittest.TestCase):
|
||||
def test_no_runs_is_needy(self):
|
||||
status, ids, needy = tc.classify_sha_runs([])
|
||||
self.assertEqual(status, tc.NONE)
|
||||
self.assertEqual(ids, ())
|
||||
self.assertTrue(needy) # absent checks => must be triggered
|
||||
|
||||
def test_success_verdict_not_needy(self):
|
||||
_, ids, needy = tc.classify_sha_runs(
|
||||
[{"status": "completed", "conclusion": "success", "databaseId": 1}])
|
||||
self.assertEqual(ids, ())
|
||||
self.assertFalse(needy)
|
||||
|
||||
def test_failure_verdict_not_needy(self):
|
||||
# A real failure is the author's to fix — never auto-retriggered (no fail loop).
|
||||
_, _, needy = tc.classify_sha_runs(
|
||||
[{"status": "completed", "conclusion": "failure", "databaseId": 1}])
|
||||
self.assertFalse(needy)
|
||||
|
||||
def test_only_cancelled_is_needy(self):
|
||||
# Cancelled leaves no verdict → re-trigger so the PR can reach a mergeable state.
|
||||
_, ids, needy = tc.classify_sha_runs(
|
||||
[{"status": "completed", "conclusion": "cancelled", "databaseId": 1}])
|
||||
self.assertEqual(ids, ())
|
||||
self.assertTrue(needy)
|
||||
|
||||
def test_in_progress_is_active_not_needy(self):
|
||||
status, ids, needy = tc.classify_sha_runs(
|
||||
[{"status": "in_progress", "conclusion": None, "databaseId": 9}])
|
||||
self.assertEqual(status, tc.RUNNING)
|
||||
self.assertEqual(ids, (9,))
|
||||
self.assertFalse(needy)
|
||||
|
||||
def test_queued_is_active_not_needy(self):
|
||||
status, ids, needy = tc.classify_sha_runs(
|
||||
[{"status": "queued", "conclusion": None, "databaseId": 8}])
|
||||
self.assertEqual(status, tc.QUEUED)
|
||||
self.assertEqual(ids, (8,))
|
||||
self.assertFalse(needy)
|
||||
|
||||
def test_running_beats_queued_in_status(self):
|
||||
status, ids, _ = tc.classify_sha_runs([
|
||||
{"status": "queued", "databaseId": 1},
|
||||
{"status": "in_progress", "databaseId": 2},
|
||||
])
|
||||
self.assertEqual(status, tc.RUNNING)
|
||||
self.assertEqual(sorted(ids), [1, 2])
|
||||
|
||||
def test_cancelled_plus_active_not_needy(self):
|
||||
# An active run already covers the SHA — cancelled siblings don't make it needy.
|
||||
_, ids, needy = tc.classify_sha_runs([
|
||||
{"status": "completed", "conclusion": "cancelled", "databaseId": 1},
|
||||
{"status": "in_progress", "databaseId": 2},
|
||||
])
|
||||
self.assertEqual(ids, (2,))
|
||||
self.assertFalse(needy)
|
||||
|
||||
|
||||
class SelectTriggersTests(unittest.TestCase):
|
||||
def test_empty_needy_triggers_nothing(self):
|
||||
dec = tc.select_triggers([npr(1), npr(2)], set(), max_inflight=3)
|
||||
self.assertEqual(dec.trigger_numbers, ())
|
||||
|
||||
def test_single_needy_triggered(self):
|
||||
dec = tc.select_triggers([npr(1)], {1}, max_inflight=3)
|
||||
self.assertEqual(dec.trigger_numbers, (1,))
|
||||
self.assertEqual(dec.cancel_run_ids, ())
|
||||
|
||||
def test_priority_order(self):
|
||||
prs = [npr(1, ["P5"]), npr(2, ["P2"]), npr(3, ["P8"])]
|
||||
dec = tc.select_triggers(prs, {1, 2, 3}, max_inflight=3)
|
||||
self.assertEqual(dec.trigger_numbers, (2, 1, 3)) # P2, P5, P8
|
||||
|
||||
def test_same_level_oldest_first(self):
|
||||
prs = [
|
||||
npr(1, ["P5"], created_at="2026-07-03T00:00:00Z"),
|
||||
npr(2, ["P5"], created_at="2026-07-01T00:00:00Z"),
|
||||
npr(3, ["P5"], created_at="2026-07-02T00:00:00Z"),
|
||||
]
|
||||
dec = tc.select_triggers(prs, {1, 2, 3}, max_inflight=3)
|
||||
self.assertEqual(dec.trigger_numbers, (2, 3, 1)) # oldest createdAt first
|
||||
|
||||
def test_cap_limits_triggers(self):
|
||||
prs = [npr(1, ["P2"]), npr(2, ["P3"]), npr(3, ["P4"])]
|
||||
dec = tc.select_triggers(prs, {1, 2, 3}, max_inflight=2)
|
||||
self.assertEqual(dec.trigger_numbers, (1, 2)) # only 2 free slots
|
||||
self.assertEqual(dec.slots, 2)
|
||||
|
||||
def test_inflight_consumes_slots(self):
|
||||
prs = [
|
||||
npr(1, ["P5"], status=tc.RUNNING, run_ids=[100]), # inflight — occupies a slot
|
||||
npr(2, ["P2"]),
|
||||
npr(3, ["P3"]),
|
||||
]
|
||||
dec = tc.select_triggers(prs, {2, 3}, max_inflight=2)
|
||||
self.assertEqual(dec.slots, 1) # 2 cap - 1 inflight
|
||||
self.assertEqual(dec.trigger_numbers, (2,)) # highest-priority needy only
|
||||
self.assertEqual(dec.inflight_numbers, (1,))
|
||||
|
||||
def test_running_needy_is_not_retriggered(self):
|
||||
# Defensive: a PR flagged needy but already running is never a candidate.
|
||||
prs = [npr(1, ["P5"], status=tc.RUNNING, run_ids=[100])]
|
||||
dec = tc.select_triggers(prs, {1}, max_inflight=3)
|
||||
self.assertEqual(dec.trigger_numbers, ())
|
||||
|
||||
def test_fork_pr_skipped(self):
|
||||
dec = tc.select_triggers([npr(1, ["P2"]), npr(2, ["P1"])],
|
||||
{1, 2}, max_inflight=3, forks={2})
|
||||
self.assertEqual(dec.trigger_numbers, (1,)) # fork #2 not token-triggerable
|
||||
self.assertEqual(dec.skipped_fork_numbers, (2,))
|
||||
|
||||
def test_p0_bypasses_cap_and_preempts_lower(self):
|
||||
# Cap full (a P5 running), but a needy P0 still triggers AND preempts the strictly-
|
||||
# lower running run to free a runner immediately.
|
||||
prs = [
|
||||
npr(1, ["P5"], status=tc.RUNNING, run_ids=[500]), # inflight, strictly-lower
|
||||
npr(2, ["P0"]), # needy emergency
|
||||
]
|
||||
dec = tc.select_triggers(prs, {2}, max_inflight=1)
|
||||
self.assertEqual(dec.slots, 0) # cap is full
|
||||
self.assertEqual(dec.trigger_numbers, (2,)) # P0 bypasses the cap
|
||||
self.assertEqual(dec.cancel_run_ids, (500,)) # preempts the lower run
|
||||
|
||||
def test_p0_does_not_preempt_equal_priority(self):
|
||||
prs = [
|
||||
npr(1, ["P0"], status=tc.RUNNING, run_ids=[500]), # equal P0 — spared
|
||||
npr(2, ["P0"]), # needy emergency
|
||||
]
|
||||
dec = tc.select_triggers(prs, {2}, max_inflight=1)
|
||||
self.assertEqual(dec.trigger_numbers, (2,))
|
||||
self.assertEqual(dec.cancel_run_ids, ()) # never preempts an equal P0
|
||||
|
||||
def test_needy_numbers_reports_all_candidates_in_order(self):
|
||||
dec = tc.select_triggers([npr(1, ["P5"]), npr(2, ["P2"])], {1, 2}, max_inflight=1)
|
||||
self.assertEqual(dec.needy_numbers, (2, 1)) # priority order, cap-independent
|
||||
self.assertEqual(dec.trigger_numbers, (2,)) # but only 1 slot triggered
|
||||
|
||||
def test_zero_cap_is_clamped_to_one(self):
|
||||
# A misconfigured cap must never stall everything: clamp to >= 1 so at least the
|
||||
# top-priority needy PR still gets a slot (fail-safe forward progress).
|
||||
dec = tc.select_triggers([npr(1, ["P5"])], {1}, max_inflight=0)
|
||||
self.assertEqual(dec.max_inflight, 1)
|
||||
self.assertEqual(dec.trigger_numbers, (1,))
|
||||
|
||||
|
||||
class TriggerStarvationTests(unittest.TestCase):
|
||||
def test_every_needy_pr_is_triggered_within_bounded_passes(self):
|
||||
# Liveness / anti-starvation: with a fixed needy set and cap=2, simulate scheduler
|
||||
# passes where a triggered PR gains a run (leaves the needy set) and its run finishes
|
||||
# one pass later (freeing its slot). Mixed priorities prove lower-priority PRs are
|
||||
# served too — never starved — while higher-priority PRs still go first.
|
||||
labels = {1: ["P1"], 2: ["P1"], 3: ["P5"], 4: ["P5"],
|
||||
5: ["P8"], 6: ["P8"], 7: ["P5"]}
|
||||
created = {n: f"2026-07-{n:02d}T00:00:00Z" for n in labels}
|
||||
needy = set(labels)
|
||||
running: dict[int, int] = {} # number -> passes left running
|
||||
triggered_ever: set[int] = set()
|
||||
first_pass: dict[int, int] = {}
|
||||
cap, max_passes = 2, 12
|
||||
for pass_no in range(1, max_passes + 1):
|
||||
snap = [
|
||||
pr(n, labels[n], created_at=created[n],
|
||||
status=tc.RUNNING if n in running else tc.NONE,
|
||||
run_ids=[1000 + n] if n in running else [])
|
||||
for n in labels
|
||||
]
|
||||
dec = tc.select_triggers(snap, needy, max_inflight=cap)
|
||||
for n in dec.trigger_numbers:
|
||||
triggered_ever.add(n)
|
||||
first_pass.setdefault(n, pass_no)
|
||||
needy.discard(n) # gained a run -> no longer needy
|
||||
running[n] = 1 # occupies a slot for one pass
|
||||
for n in list(running): # running PRs finish after one pass
|
||||
running[n] -= 1
|
||||
if running[n] <= 0:
|
||||
del running[n]
|
||||
if not needy and not running:
|
||||
break
|
||||
self.assertEqual(triggered_ever, set(labels),
|
||||
"a PR was starved (never triggered)")
|
||||
# Priority respected: the two P1s go in the very first pass; everything else later.
|
||||
self.assertTrue(all(first_pass[n] == 1 for n in (1, 2)))
|
||||
self.assertTrue(all(first_pass[n] >= 2 for n in (3, 4, 5, 6, 7)))
|
||||
|
||||
|
||||
class TriggerSnapshotParsingTests(unittest.TestCase):
|
||||
def test_load_trigger_snapshot_roundtrip(self):
|
||||
text = json.dumps({
|
||||
"max_inflight": 2,
|
||||
"needy": [1, 3],
|
||||
"forks": [3],
|
||||
"prs": [
|
||||
{"number": 1, "labels": ["P2"], "createdAt": "2026-07-01T00:00:00Z"},
|
||||
{"number": 2, "labels": ["P5"], "runStatus": "running", "runIds": [22]},
|
||||
{"number": 3, "labels": ["P1"], "createdAt": "2026-07-02T00:00:00Z"},
|
||||
],
|
||||
})
|
||||
all_prs, needy, forks, cap = tc._load_trigger_snapshot(text)
|
||||
self.assertEqual(len(all_prs), 3)
|
||||
self.assertEqual(needy, {1, 3})
|
||||
self.assertEqual(forks, {3})
|
||||
self.assertEqual(cap, 2)
|
||||
dec = tc.select_triggers(all_prs, needy, max_inflight=cap, forks=forks)
|
||||
# #2 is inflight (uses a slot); #3 is a fork (skipped); only #1 fits the 1 free slot.
|
||||
self.assertEqual(dec.inflight_numbers, (2,))
|
||||
self.assertEqual(dec.skipped_fork_numbers, (3,))
|
||||
self.assertEqual(dec.trigger_numbers, (1,))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,805 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""CI traffic-controller: priority-based runner orchestration for LibreMail's
|
||||
`ci.yml`. Extracted out of the old inline-bash `traffic-control` step into a
|
||||
Python module so the decision logic is developer-legible and, above all, unit-
|
||||
testable (see `test_traffic_control.py`).
|
||||
|
||||
DESIGN: pure decision CORE + thin gh-I/O SHELL
|
||||
----------------------------------------------
|
||||
The decisions ("who do we cancel?", "do we proceed or wait?") are pure functions
|
||||
over plain `PullRequest` snapshots — no network, no clock, no subprocess — so they
|
||||
can be exercised exhaustively in unit tests. The SHELL (`run_live`) is the only part
|
||||
that touches `gh`: it gathers the snapshot, applies the cancellations, and runs the
|
||||
bounded hold-back poll loop. Feed the core a snapshot JSON (`--dry-run`) to see its
|
||||
decisions with zero network.
|
||||
|
||||
TWO MODES
|
||||
---------
|
||||
* ``--mode orchestrate`` (default; unchanged behaviour): the in-run `traffic-control`
|
||||
job of `ci.yml`. Orders runner ACCESS for the PR whose run is already executing —
|
||||
PASS 1 preemption + PASS 2 hold-back (below). This is `run_live`.
|
||||
* ``--mode trigger`` (issue #349): the *scheduler* (companion `ci-trigger.yml`, run
|
||||
after each auto-update and on a cron backstop). It OWNS CI *triggering*: it
|
||||
(re-)triggers CI for the highest-priority PR(s) whose head SHA has absent/stale
|
||||
required checks — a few at a time (an inflight cap), in the SAME priority order —
|
||||
via a `workflow_dispatch`. This is `run_trigger` / the pure `select_triggers`.
|
||||
|
||||
WHY --mode trigger EXISTS (issue #349): `autoupdate.yml` now updates PR branches with the
|
||||
built-in GITHUB_TOKEN instead of a PAT, so an update push no longer auto-retriggers CI
|
||||
(GitHub's anti-recursion rule) — killing the merge-cascade that cancelled every open PR's
|
||||
run on every merge. The cost is that a freshly-updated PR's required checks go stale/absent
|
||||
on its NEW head SHA, so this scheduler deliberately (re-)triggers them in priority order (a
|
||||
poor-man's merge queue). The dispatch uses the AUTOUPDATE_TOKEN PAT, NOT the built-in
|
||||
GITHUB_TOKEN: a GITHUB_TOKEN-triggered run is held for MANUAL approval (`action_required`) and
|
||||
never runs un-attended, whereas a PAT dispatch runs as the authorized owner with no approval gate
|
||||
(#350's "no PAT needed" claim was wrong — see ci-trigger.yml + issue #351). FAIL-OPEN,
|
||||
structurally: `ci.yml` KEEPS its `on: pull_request`
|
||||
trigger, so any human push — and a brand-new PR — always gets CI regardless of this
|
||||
scheduler; the scheduler only fills the gap left by GITHUB_TOKEN auto-updates and can never
|
||||
leave a PR un-triggerable. Fork PRs (no token/secret access) are skipped by the scheduler and
|
||||
left to `on: pull_request`, so they are never wedged either.
|
||||
|
||||
ORDER OF OPERATIONS (issue #342)
|
||||
--------------------------------
|
||||
1. Effective priority orders everything: the lowest-numbered `P0`-`P9` label present
|
||||
(P0 = highest), default `P5` if none. A `broken` OR `draft` PR is effectively P10
|
||||
(bottom, below P9), overriding any P0-P9 label.
|
||||
2. PASS 1 - preemption: a strictly-lower OTHER PR's in-progress / queued run is
|
||||
cancelled iff THIS PR is P0 (an emergency reclaims ALL lower runners) OR the target
|
||||
is broken/draft (a wasted run any higher-priority PR may reclaim). P1-P9 never bump
|
||||
a *normal* lower run mid-flight — only a P0 does that.
|
||||
3. PASS 2 - bounded hold-back: a non-P0 PR yields (cancels nothing) to any strictly-
|
||||
higher-priority OTHER PR that has an active/queued run, and — among its OWN
|
||||
priority level — to any PR ordered ahead of it (running-first, then oldest by
|
||||
`createdAt`). It proceeds the moment it is at the front, or when the wait budget
|
||||
elapses (a PR never blocks itself).
|
||||
|
||||
SAFETY INVARIANTS (preserved from the original step)
|
||||
----------------------------------------------------
|
||||
* never cancel a run on `main` / a push event — the shell's `gh run list` query
|
||||
filters `--event pull_request` and drops `headBranch == main`, so only PR-event
|
||||
runs ever reach the core;
|
||||
* never cancel THIS PR's own run — skipped by PR number AND by run id;
|
||||
* never cancel an equal-or-higher-priority PR — only strictly-lower (prio > self).
|
||||
|
||||
This is deliberately NOT a merge gate: every gh call is guarded, the shell always
|
||||
exits 0, and the ci.yml step stays `continue-on-error`, so a hiccup (API error,
|
||||
missing permission, fork PR) can never fail CI.
|
||||
|
||||
Pure standard library, cross-platform (the primary dev box is Windows, where the old
|
||||
bash + `jq` pipeline had no clean equivalent).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# ── Priority model ───────────────────────────────────────────────────────────
|
||||
BROKEN_LABEL = "broken"
|
||||
DEFAULT_PRIORITY = 5 # a PR with no P0-P9 label
|
||||
BOTTOM_PRIORITY = 10 # broken OR draft — below P9
|
||||
TOP_PRIORITY = 0 # P0, the only priority that preempts
|
||||
_P_LABEL = re.compile(r"^P([0-9])$") # single digit only, matching the old jq `^P[0-9]$`
|
||||
|
||||
# ── Run-status model (normalised from gh's raw run statuses) ─────────────────
|
||||
RUNNING = "running" # gh status in_progress
|
||||
QUEUED = "queued" # gh status queued / waiting / requested / pending
|
||||
NONE = "none" # no active run (completed or absent)
|
||||
ACTIVE = frozenset({RUNNING, QUEUED})
|
||||
# For aggregating a branch's overall status from its runs: running beats queued
|
||||
# beats none (most-active wins). NB: the same-level ORDER (see _ordering_key) is a
|
||||
# coarser two-bucket split — in-flight (running) vs everything-else-by-age.
|
||||
_STATUS_RANK = {RUNNING: 0, QUEUED: 1, NONE: 2}
|
||||
# createdAt sentinel so a PR with an unknown timestamp sorts LAST (never wrongly
|
||||
# "oldest"/front, so it yields rather than preempts another PR's front slot).
|
||||
_FAR_FUTURE = "9999-12-31T23:59:59Z"
|
||||
# Run conclusions that count as a FINAL VERDICT on a head SHA (issue #349, --mode trigger).
|
||||
# A SHA with one of these is NOT re-triggered: success = green, failure/timeout/etc. = the
|
||||
# author's to fix — auto-retriggering a real failure would waste runners and could loop.
|
||||
# Everything else a completed run can report (cancelled / skipped / stale / startup_failure /
|
||||
# null) is treated as "no verdict", so a SHA whose only runs are those — or that has no run at
|
||||
# all (absent checks after a GITHUB_TOKEN auto-update) — is NEEDY and gets (re-)triggered.
|
||||
VERDICT_CONCLUSIONS = frozenset(
|
||||
{"success", "failure", "timed_out", "action_required", "neutral"}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PullRequest:
|
||||
"""A snapshot of one open PR. The pure decision core consumes only these — no
|
||||
network. `run_ids` are the PR's active (non-completed) CI run ids, already
|
||||
filtered to pull_request events on a non-main head by the shell that built them."""
|
||||
|
||||
number: int
|
||||
labels: tuple[str, ...] = ()
|
||||
is_draft: bool = False
|
||||
created_at: str = ""
|
||||
run_status: str = NONE
|
||||
run_ids: tuple[int, ...] = ()
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, obj: dict) -> "PullRequest":
|
||||
"""Build from a snapshot dict. `labels` may be a list of names or of gh's
|
||||
label objects (`{"name": ...}`)."""
|
||||
raw_labels = obj.get("labels") or []
|
||||
names: list[str] = []
|
||||
for lab in raw_labels:
|
||||
if isinstance(lab, dict):
|
||||
name = lab.get("name")
|
||||
else:
|
||||
name = lab
|
||||
if name:
|
||||
names.append(str(name))
|
||||
status = (obj.get("runStatus") or obj.get("run_status") or NONE).lower()
|
||||
if status not in (RUNNING, QUEUED, NONE):
|
||||
status = NONE
|
||||
raw_ids = obj.get("runIds") or obj.get("run_ids") or ()
|
||||
return cls(
|
||||
number=int(obj["number"]),
|
||||
labels=tuple(names),
|
||||
is_draft=bool(obj.get("isDraft") or obj.get("is_draft")
|
||||
or obj.get("draft") or False),
|
||||
created_at=str(obj.get("createdAt") or obj.get("created_at") or ""),
|
||||
run_status=status,
|
||||
run_ids=tuple(int(r) for r in raw_ids),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Blocker:
|
||||
"""A PR that THIS PR must yield to in PASS 2 (purely informational for logging)."""
|
||||
|
||||
number: int
|
||||
priority: int
|
||||
kind: str # "higher-priority" | "same-level-ahead"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Decision:
|
||||
"""The full point-in-time decision for THIS PR (used by --dry-run and tests)."""
|
||||
|
||||
self_number: int
|
||||
self_priority: int
|
||||
cancel_run_ids: tuple[int, ...] = ()
|
||||
blockers: tuple[Blocker, ...] = ()
|
||||
proceed: bool = True
|
||||
|
||||
|
||||
# ── Pure decision core (no network / clock / subprocess) ─────────────────────
|
||||
def effective_priority(pr: PullRequest) -> int:
|
||||
"""Effective priority: `broken` OR `draft` => 10 (bottom, overriding any P0-P9);
|
||||
else the lowest-numbered P0-P9 label present; else the default P5."""
|
||||
if pr.is_draft or BROKEN_LABEL in pr.labels:
|
||||
return BOTTOM_PRIORITY
|
||||
nums = [int(m.group(1)) for name in pr.labels if (m := _P_LABEL.match(name))]
|
||||
return min(nums) if nums else DEFAULT_PRIORITY
|
||||
|
||||
|
||||
def priority_label(prio: int) -> str:
|
||||
"""Human-readable priority for logs."""
|
||||
if prio >= BOTTOM_PRIORITY:
|
||||
return f"P{BOTTOM_PRIORITY} (broken/draft — bottom, below P9)"
|
||||
return f"P{prio}"
|
||||
|
||||
|
||||
def _ordering_key(pr: PullRequest) -> tuple[int, str, int]:
|
||||
"""Same-level ordering (issue #342 rule 3): an in-flight (RUNNING) run keeps its
|
||||
place at the front — a same-level peer never reorders it — then, among the PRs
|
||||
still waiting to start (QUEUED or no run yet), OLDEST createdAt first (ascending),
|
||||
then PR number as a stable final tiebreak so the order is fully deterministic."""
|
||||
in_flight = 0 if pr.run_status == RUNNING else 1
|
||||
return (in_flight, pr.created_at or _FAR_FUTURE, pr.number)
|
||||
|
||||
|
||||
def runs_to_cancel(
|
||||
this_pr: PullRequest,
|
||||
all_prs: list[PullRequest],
|
||||
*,
|
||||
self_run_id: int | None = None,
|
||||
) -> list[int]:
|
||||
"""PASS 1. Run ids to cancel. A strictly-lower OTHER PR's active (running/queued)
|
||||
run is cancelled iff keeping it running is wasteful, i.e. EITHER:
|
||||
* THIS PR is P0 — an emergency reclaims every strictly-lower runner now; OR
|
||||
* the target is broken/draft (effective priority 10) — its run can't merge /
|
||||
isn't merge-ready, so ANY higher-priority PR may reclaim its runner.
|
||||
P1-P9 never cancel a *normal* strictly-lower run — they yield in PASS 2 instead.
|
||||
Invariants: never cancel self (by number or run id), never cancel an
|
||||
equal-or-higher-priority PR (only strictly-lower, prio > self)."""
|
||||
self_prio = effective_priority(this_pr)
|
||||
to_cancel: list[int] = []
|
||||
seen: set[int] = set()
|
||||
for pr in all_prs:
|
||||
if pr.number == this_pr.number:
|
||||
continue # never cancel self
|
||||
target_prio = effective_priority(pr)
|
||||
if target_prio <= self_prio:
|
||||
continue # only strictly-lower (skip equal-or-higher)
|
||||
if pr.run_status not in ACTIVE:
|
||||
continue # nothing running/queued to cancel
|
||||
# Strictly lower: preemptible iff we're P0 OR the target is broken/draft
|
||||
# (a bottom, priority-10, wasted run that any higher PR may reclaim).
|
||||
if self_prio != TOP_PRIORITY and target_prio < BOTTOM_PRIORITY:
|
||||
continue # P1-P9 don't bump a *normal* lower run
|
||||
for rid in pr.run_ids:
|
||||
if self_run_id is not None and rid == self_run_id:
|
||||
continue # never cancel our own run
|
||||
if rid in seen:
|
||||
continue
|
||||
seen.add(rid)
|
||||
to_cancel.append(rid)
|
||||
return to_cancel
|
||||
|
||||
|
||||
def wait_blockers(this_pr: PullRequest, all_prs: list[PullRequest]) -> list[Blocker]:
|
||||
"""PASS 2. The PRs THIS PR must yield to right now (empty => proceed). P0 never
|
||||
yields. Otherwise yield to (a) any strictly-higher-priority OTHER PR with an
|
||||
active/queued run, and (b) any SAME-priority PR ordered ahead of THIS PR
|
||||
(running-first, then oldest createdAt)."""
|
||||
self_prio = effective_priority(this_pr)
|
||||
if self_prio == TOP_PRIORITY:
|
||||
return [] # P0 outranks everything — never wait
|
||||
|
||||
others = [pr for pr in all_prs if pr.number != this_pr.number]
|
||||
blockers: list[Blocker] = []
|
||||
|
||||
# (a) strictly-higher-priority PRs that actually have an active/queued run.
|
||||
for pr in others:
|
||||
p = effective_priority(pr)
|
||||
if p < self_prio and pr.run_status in ACTIVE:
|
||||
blockers.append(Blocker(pr.number, p, "higher-priority"))
|
||||
|
||||
# (b) same-level ordering: THIS PR proceeds only when it is at the front.
|
||||
same_level = [pr for pr in others if effective_priority(pr) == self_prio]
|
||||
same_level.append(this_pr) # this_pr appears exactly once
|
||||
for pr in sorted(same_level, key=_ordering_key):
|
||||
if pr.number == this_pr.number:
|
||||
break # reached self => nobody ahead remains
|
||||
blockers.append(Blocker(pr.number, self_prio, "same-level-ahead"))
|
||||
|
||||
return blockers
|
||||
|
||||
|
||||
def decide(
|
||||
this_pr: PullRequest,
|
||||
all_prs: list[PullRequest],
|
||||
*,
|
||||
self_run_id: int | None = None,
|
||||
) -> Decision:
|
||||
"""Convenience: the full point-in-time decision (both passes) for THIS PR."""
|
||||
cancels = runs_to_cancel(this_pr, all_prs, self_run_id=self_run_id)
|
||||
blockers = wait_blockers(this_pr, all_prs)
|
||||
return Decision(
|
||||
self_number=this_pr.number,
|
||||
self_priority=effective_priority(this_pr),
|
||||
cancel_run_ids=tuple(cancels),
|
||||
blockers=tuple(blockers),
|
||||
proceed=not blockers,
|
||||
)
|
||||
|
||||
|
||||
# ── Pure TRIGGER-decision core (issue #349, --mode trigger) ──────────────────
|
||||
def classify_sha_runs(runs: list[dict]) -> tuple[str, tuple[int, ...], bool]:
|
||||
"""PURE. Summarise the CI runs on ONE head SHA. Returns (run_status, active_run_ids,
|
||||
needy):
|
||||
* run_status: RUNNING if any run is in progress, else QUEUED if any is queued/pending,
|
||||
else NONE;
|
||||
* active_run_ids: databaseIds of the non-completed (running/queued) runs;
|
||||
* needy: True iff the SHA has NO active run AND NO run with a final VERDICT — i.e. its
|
||||
required checks are absent/stale (a fresh SHA after a GITHUB_TOKEN auto-update) or
|
||||
only cancelled/infra-aborted, so the PR cannot merge until CI is (re-)triggered on
|
||||
that SHA. A success/failure/timeout verdict is NOT needy (green, or the author's to
|
||||
fix — never auto-retried)."""
|
||||
status = NONE
|
||||
active_ids: list[int] = []
|
||||
has_verdict = False
|
||||
for r in runs:
|
||||
raw = (r.get("status") or "").lower()
|
||||
if raw == "completed":
|
||||
if (r.get("conclusion") or "").lower() in VERDICT_CONCLUSIONS:
|
||||
has_verdict = True
|
||||
continue
|
||||
norm = _normalise_status(raw) # in_progress -> running; else queued
|
||||
if _STATUS_RANK[norm] < _STATUS_RANK[status]:
|
||||
status = norm
|
||||
rid = r.get("databaseId")
|
||||
if rid is not None:
|
||||
active_ids.append(int(rid))
|
||||
needy = not active_ids and not has_verdict
|
||||
return status, tuple(active_ids), needy
|
||||
|
||||
|
||||
def _trigger_order_key(pr: PullRequest) -> tuple[int, str, int]:
|
||||
"""Trigger ordering: highest priority first (lowest effective-priority number), then
|
||||
OLDEST createdAt first (the longest-waiting PR at a level goes first — the same-level
|
||||
fairness / anti-starvation rule), then PR number as a stable final tiebreak."""
|
||||
return (effective_priority(pr), pr.created_at or _FAR_FUTURE, pr.number)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TriggerDecision:
|
||||
"""PURE output of `select_triggers`: which PR(s) the scheduler should (re-)trigger CI
|
||||
for right now, in order, plus any strictly-lower runs a P0 emergency preempts to free a
|
||||
runner. Exercised by `--mode trigger --dry-run` and the unit tests."""
|
||||
|
||||
trigger_numbers: tuple[int, ...] = ()
|
||||
cancel_run_ids: tuple[int, ...] = ()
|
||||
inflight_numbers: tuple[int, ...] = ()
|
||||
needy_numbers: tuple[int, ...] = ()
|
||||
skipped_fork_numbers: tuple[int, ...] = ()
|
||||
slots: int = 0
|
||||
max_inflight: int = 0
|
||||
|
||||
|
||||
def select_triggers(
|
||||
all_prs: list[PullRequest],
|
||||
needy: "set[int] | frozenset[int]",
|
||||
*,
|
||||
max_inflight: int,
|
||||
forks: "set[int] | frozenset[int]" = frozenset(),
|
||||
) -> TriggerDecision:
|
||||
"""PURE. Choose the PR(s) to (re-)trigger CI for now — a poor-man's merge queue over the
|
||||
existing priority model. No network / clock / subprocess, so it is exhaustively unit-
|
||||
tested (see TestSelectTriggers / TestTriggerStarvation).
|
||||
|
||||
* inflight = PRs already running/queued on their head SHA — they occupy the cap.
|
||||
* candidates = NEEDY PRs (absent/stale checks on their head SHA) that are not already
|
||||
running and are not forks (forks have no token/secret access — see `run_trigger`).
|
||||
* order = effective priority, then oldest createdAt, then number (`_trigger_order_key`).
|
||||
* P0 = EMERGENCY: always triggered, BYPASSING the cap, and it PREEMPTS its strictly-lower
|
||||
OTHER runs (reusing `runs_to_cancel`) so a runner frees for it immediately.
|
||||
* P1–P10 fill only the remaining ``slots = max_inflight - len(inflight)``; the rest wait
|
||||
for a later pass.
|
||||
|
||||
STARVATION is bounded, not by aging but structurally: triggering a PR gives its head SHA
|
||||
a run, so it LEAVES the needy set; between merges the needy set only shrinks, and the
|
||||
scheduler re-runs on every auto-update plus a cron backstop, so every eligible PR is
|
||||
triggered within a bounded number of passes (proved by TestTriggerStarvation). Ordering
|
||||
is still by priority, so higher-priority PRs are simply served first, never exclusively
|
||||
forever (a served PR stops being needy until its next push/auto-update)."""
|
||||
cap = max(1, max_inflight)
|
||||
inflight = [p for p in all_prs if p.run_status in ACTIVE]
|
||||
candidates = [
|
||||
p for p in all_prs
|
||||
if p.number in needy and p.run_status not in ACTIVE and p.number not in forks
|
||||
]
|
||||
ordered = sorted(candidates, key=_trigger_order_key)
|
||||
emergencies = [p for p in ordered if effective_priority(p) == TOP_PRIORITY]
|
||||
normal = [p for p in ordered if effective_priority(p) != TOP_PRIORITY]
|
||||
slots = max(0, cap - len(inflight))
|
||||
chosen = emergencies + normal[:slots] # P0 bypasses the cap; P1–P10 fill free slots
|
||||
|
||||
cancel_ids: list[int] = []
|
||||
seen: set[int] = set()
|
||||
for emergency in emergencies: # P0 preempts its strictly-lower active runs
|
||||
for rid in runs_to_cancel(emergency, all_prs):
|
||||
if rid not in seen:
|
||||
seen.add(rid)
|
||||
cancel_ids.append(rid)
|
||||
|
||||
return TriggerDecision(
|
||||
trigger_numbers=tuple(p.number for p in chosen),
|
||||
cancel_run_ids=tuple(cancel_ids),
|
||||
inflight_numbers=tuple(sorted(p.number for p in inflight)),
|
||||
needy_numbers=tuple(p.number for p in ordered),
|
||||
skipped_fork_numbers=tuple(sorted(n for n in needy if n in forks)),
|
||||
slots=slots,
|
||||
max_inflight=cap,
|
||||
)
|
||||
|
||||
|
||||
# ── gh I/O shell (the only part that touches the network) ────────────────────
|
||||
def _log(msg: str) -> None:
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def _gh_json(args: list[str]) -> list | dict | None:
|
||||
"""Run `gh <args> --json ...` and parse stdout as JSON. Returns None (never
|
||||
raises) on any failure — the caller fails open."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["gh", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
_log(f"::warning::gh invocation failed ({' '.join(args[:2])}): {exc}")
|
||||
return None
|
||||
if proc.returncode != 0:
|
||||
_log(f"::warning::gh exited {proc.returncode} ({' '.join(args[:2])}): "
|
||||
f"{proc.stderr.strip()}")
|
||||
return None
|
||||
try:
|
||||
return json.loads(proc.stdout or "null")
|
||||
except json.JSONDecodeError as exc:
|
||||
_log(f"::warning::could not parse gh JSON ({' '.join(args[:2])}): {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def _normalise_status(raw: str) -> str:
|
||||
"""Map a gh run status onto our RUNNING / QUEUED / NONE model."""
|
||||
if raw == "in_progress":
|
||||
return RUNNING
|
||||
if raw == "completed":
|
||||
return NONE
|
||||
return QUEUED # queued / waiting / requested / pending
|
||||
|
||||
|
||||
def _runs_by_head(limit: int = 300) -> dict[str, dict]:
|
||||
"""One bulk `gh run list` -> {headBranch: {"status", "ids"}} for active PR-event
|
||||
runs. Enforces the 'never cancel main/push' invariant at the source: only
|
||||
`event == pull_request`, non-`main`, non-completed runs are kept. Active runs are
|
||||
the most recent, so `limit` most-recent runs comfortably covers them."""
|
||||
rows = _gh_json([
|
||||
"run", "list", "--workflow", "ci.yml", "--event", "pull_request",
|
||||
"--limit", str(limit),
|
||||
"--json", "databaseId,status,headBranch,event",
|
||||
])
|
||||
by_head: dict[str, dict] = {}
|
||||
for row in rows or []:
|
||||
if row.get("event") != "pull_request":
|
||||
continue
|
||||
head = row.get("headBranch")
|
||||
if not head or head == "main":
|
||||
continue
|
||||
if row.get("status") == "completed":
|
||||
continue
|
||||
entry = by_head.setdefault(head, {"status": NONE, "ids": []})
|
||||
entry["ids"].append(int(row["databaseId"]))
|
||||
status = _normalise_status(row.get("status", ""))
|
||||
# running beats queued beats none for the branch's aggregate status.
|
||||
if _STATUS_RANK[status] < _STATUS_RANK[entry["status"]]:
|
||||
entry["status"] = status
|
||||
return by_head
|
||||
|
||||
|
||||
def gather_snapshot(self_pr_number: int) -> tuple[PullRequest | None, list[PullRequest]]:
|
||||
"""Build (this_pr, all_prs) from live gh data. this_pr is forced to RUNNING —
|
||||
by definition our own run is in progress while this job executes."""
|
||||
prs = _gh_json([
|
||||
"pr", "list", "--state", "open", "--limit", "300",
|
||||
"--json", "number,headRefName,labels,isDraft,createdAt",
|
||||
])
|
||||
if prs is None:
|
||||
return None, []
|
||||
runs = _runs_by_head()
|
||||
all_prs: list[PullRequest] = []
|
||||
this_pr: PullRequest | None = None
|
||||
for obj in prs:
|
||||
head = obj.get("headRefName") or ""
|
||||
run_info = runs.get(head, {"status": NONE, "ids": []})
|
||||
number = int(obj["number"])
|
||||
is_self = number == self_pr_number
|
||||
pr = PullRequest.from_json({
|
||||
**obj,
|
||||
# self is definitionally running (this job is in progress).
|
||||
"runStatus": RUNNING if is_self else run_info["status"],
|
||||
"runIds": run_info["ids"],
|
||||
})
|
||||
all_prs.append(pr)
|
||||
if is_self:
|
||||
this_pr = pr
|
||||
return this_pr, all_prs
|
||||
|
||||
|
||||
def _cancel_run(run_id: int) -> bool:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["gh", "run", "cancel", str(run_id)],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
_log(f"::warning::could not cancel run {run_id}: {exc}")
|
||||
return False
|
||||
if proc.returncode == 0:
|
||||
return True
|
||||
_log(f"::warning::could not cancel run {run_id} — likely already finished. "
|
||||
f"{proc.stderr.strip()}")
|
||||
return False
|
||||
|
||||
|
||||
def _positive_int(env_name: str, default: int) -> int:
|
||||
raw = os.environ.get(env_name, "")
|
||||
return int(raw) if raw.isdigit() and int(raw) > 0 else default
|
||||
|
||||
|
||||
def run_live() -> int:
|
||||
"""The gh-driven shell: gather, PASS 1 (cancel), PASS 2 (bounded hold-back). Always
|
||||
returns 0 — the traffic-controller must never fail CI."""
|
||||
event = os.environ.get("GITHUB_EVENT_NAME", "")
|
||||
self_raw = os.environ.get("SELF_PR", "")
|
||||
if event != "pull_request" or not self_raw.isdigit():
|
||||
_log("Not a pull_request event (or no PR number) — nothing to do.")
|
||||
return 0
|
||||
self_number = int(self_raw)
|
||||
self_run_id = int(os.environ["GITHUB_RUN_ID"]) if os.environ.get(
|
||||
"GITHUB_RUN_ID", "").isdigit() else None
|
||||
|
||||
this_pr, all_prs = gather_snapshot(self_number)
|
||||
if this_pr is None:
|
||||
_log("::warning::Could not resolve THIS PR from the open-PR list — skipping.")
|
||||
return 0
|
||||
|
||||
self_prio = effective_priority(this_pr)
|
||||
_log(f"This PR #{self_number} effective priority: {priority_label(self_prio)} "
|
||||
"(P0 = highest/emergency, P9 = lowest, broken/draft = bottom).")
|
||||
|
||||
# ── PASS 1: PREEMPTION (P0 reclaims all lower; anyone reclaims broken/draft) ──
|
||||
to_cancel = runs_to_cancel(this_pr, all_prs, self_run_id=self_run_id)
|
||||
if not to_cancel:
|
||||
if self_prio == TOP_PRIORITY:
|
||||
_log("P0 emergency — no strictly-lower active runs to cancel.")
|
||||
else:
|
||||
_log("No preemptible runs (P1-P9 only reclaim broken/draft lower runs; "
|
||||
"none active).")
|
||||
else:
|
||||
cancelled = 0
|
||||
for rid in to_cancel:
|
||||
if _cancel_run(rid):
|
||||
_log(f" cancelled run {rid} (freed its runner).")
|
||||
cancelled += 1
|
||||
_log(f"P0 preemption complete — cancelled {cancelled}/{len(to_cancel)} run(s).")
|
||||
|
||||
# ── PASS 2: BOUNDED HOLD-BACK (yield to higher / same-level-ahead) ────────
|
||||
if self_prio == TOP_PRIORITY:
|
||||
_log("P0 emergency — not yielding; proceeding immediately.")
|
||||
return 0
|
||||
|
||||
budget = _positive_int("HOLD_BACK_BUDGET_SECONDS", 180)
|
||||
poll = _positive_int("HOLD_BACK_POLL_SECONDS", 15)
|
||||
deadline = time.monotonic() + budget
|
||||
_log(f"{priority_label(self_prio)} — holding back up to {budget}s for higher / "
|
||||
"earlier same-level PRs (no cancellation).")
|
||||
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
_log("Hold-back budget elapsed — proceeding; higher-priority PRs got their "
|
||||
"head start.")
|
||||
break
|
||||
# Refresh OTHER PRs so newly-opened higher-priority PRs are seen mid-wait;
|
||||
# THIS PR's own identity/priority stays fixed (matching the original).
|
||||
_, fresh = gather_snapshot(self_number)
|
||||
if not fresh:
|
||||
_log("::warning::Could not refresh open PRs — proceeding.")
|
||||
break
|
||||
blockers = wait_blockers(this_pr, fresh)
|
||||
if not blockers:
|
||||
_log("No higher-priority or earlier same-level PR is ahead — proceeding.")
|
||||
break
|
||||
tags = " ".join(f"#{b.number}(P{b.priority},{b.kind})" for b in blockers)
|
||||
sleep_s = min(poll, int(remaining)) if remaining >= 1 else 0
|
||||
_log(f"Yielding to: {tags} — re-checking in {sleep_s}s "
|
||||
f"({int(remaining)}s budget left).")
|
||||
if sleep_s > 0:
|
||||
time.sleep(sleep_s)
|
||||
|
||||
_log("Hold-back complete — this PR's heavy jobs may now start.")
|
||||
return 0
|
||||
|
||||
|
||||
# ── --dry-run: feed the pure core a snapshot JSON, print its decisions ───────
|
||||
def _load_snapshot(text: str) -> tuple[PullRequest, list[PullRequest], int | None]:
|
||||
data = json.loads(text)
|
||||
all_prs = [PullRequest.from_json(o) for o in data.get("prs", [])]
|
||||
self_number = int(data["self"])
|
||||
self_run_id = data.get("self_run_id")
|
||||
self_run_id = int(self_run_id) if self_run_id is not None else None
|
||||
this_pr = next((p for p in all_prs if p.number == self_number), None)
|
||||
if this_pr is None:
|
||||
raise ValueError(f"self #{self_number} not present in prs[]")
|
||||
return this_pr, all_prs, self_run_id
|
||||
|
||||
|
||||
def run_dry(text: str) -> int:
|
||||
this_pr, all_prs, self_run_id = _load_snapshot(text)
|
||||
dec = decide(this_pr, all_prs, self_run_id=self_run_id)
|
||||
_log(f"This PR #{dec.self_number} effective priority: "
|
||||
f"{priority_label(dec.self_priority)}")
|
||||
if dec.cancel_run_ids:
|
||||
why = ("P0 emergency (reclaims all strictly-lower)"
|
||||
if dec.self_priority == TOP_PRIORITY
|
||||
else "reclaiming broken/draft lower runs")
|
||||
_log(f"PASS 1 (preemption): {why} — cancel run ids: "
|
||||
f"{list(dec.cancel_run_ids)}")
|
||||
else:
|
||||
_log("PASS 1 (preemption): nothing to cancel.")
|
||||
if dec.proceed:
|
||||
_log("PASS 2 (hold-back): PROCEED — no blockers.")
|
||||
else:
|
||||
tags = ", ".join(f"#{b.number}(P{b.priority}, {b.kind})" for b in dec.blockers)
|
||||
_log(f"PASS 2 (hold-back): WAIT — yielding to: {tags}")
|
||||
return 0
|
||||
|
||||
|
||||
# ── --mode trigger: the PAT-free scheduler shell (issue #349) ────────────────
|
||||
def _gh_ok(args: list[str]) -> bool:
|
||||
"""Run `gh <args>` for its side effect (no JSON parse). Returns True on exit 0; never
|
||||
raises — the scheduler fails open on any I/O error."""
|
||||
try:
|
||||
proc = subprocess.run(["gh", *args], capture_output=True, text=True, check=False)
|
||||
except (OSError, ValueError) as exc:
|
||||
_log(f"::warning::gh invocation failed ({' '.join(args[:2])}): {exc}")
|
||||
return False
|
||||
if proc.returncode != 0:
|
||||
_log(f"::warning::gh exited {proc.returncode} ({' '.join(args[:3])}): "
|
||||
f"{proc.stderr.strip()}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _ci_workflow_file() -> str:
|
||||
return os.environ.get("CI_WORKFLOW_FILE", "ci.yml")
|
||||
|
||||
|
||||
def gather_trigger_snapshot() -> tuple[list[PullRequest], set[int], set[int], dict[int, dict]]:
|
||||
"""Build (all_prs, needy, forks, meta) from live gh data for the trigger scheduler.
|
||||
* all_prs: PullRequest snapshots whose run_status / run_ids reflect the runs on each
|
||||
PR's CURRENT head SHA (so 'inflight' means a live run on the mergeable SHA, never a
|
||||
stale one on a superseded SHA);
|
||||
* needy: PR numbers whose head SHA has absent/stale checks (must be (re-)triggered);
|
||||
* forks: cross-repository PR numbers — no token/secret access, so NOT token-triggerable;
|
||||
* meta: number -> {headRefName, headRefOid} for the dispatch I/O.
|
||||
Returns empty structures (never raises) if gh can't be reached — the caller fails open."""
|
||||
prs = _gh_json([
|
||||
"pr", "list", "--state", "open", "--limit", "300",
|
||||
"--json", "number,headRefName,headRefOid,isCrossRepository,labels,isDraft,createdAt",
|
||||
])
|
||||
if prs is None:
|
||||
return [], set(), set(), {}
|
||||
runs = _gh_json([
|
||||
"run", "list", "--workflow", _ci_workflow_file(), "--limit", "300",
|
||||
"--json", "databaseId,status,conclusion,headSha,headBranch,event",
|
||||
]) or []
|
||||
by_sha: dict[str, list[dict]] = {}
|
||||
for row in runs:
|
||||
sha = row.get("headSha")
|
||||
if sha:
|
||||
by_sha.setdefault(sha, []).append(row)
|
||||
|
||||
all_prs: list[PullRequest] = []
|
||||
needy: set[int] = set()
|
||||
forks: set[int] = set()
|
||||
meta: dict[int, dict] = {}
|
||||
for obj in prs:
|
||||
number = int(obj["number"])
|
||||
sha = obj.get("headRefOid") or ""
|
||||
status, run_ids, is_needy = classify_sha_runs(by_sha.get(sha, []))
|
||||
all_prs.append(PullRequest.from_json(
|
||||
{**obj, "runStatus": status, "runIds": list(run_ids)}))
|
||||
meta[number] = {
|
||||
"headRefName": obj.get("headRefName") or "",
|
||||
"headRefOid": sha,
|
||||
}
|
||||
if obj.get("isCrossRepository"):
|
||||
forks.add(number)
|
||||
if is_needy:
|
||||
needy.add(number)
|
||||
return all_prs, needy, forks, meta
|
||||
|
||||
|
||||
def _dispatch_ci(pr_number: int, head_ref: str, head_sha: str) -> bool:
|
||||
"""Trigger `ci.yml` for one PR via a `workflow_dispatch` on the PR's head branch. The
|
||||
dispatch runs as GH_TOKEN, which ci-trigger.yml sets to the AUTOUPDATE_TOKEN PAT: a run
|
||||
triggered by the built-in GITHUB_TOKEN is held for MANUAL approval (`action_required`) and
|
||||
never runs un-attended, so the PAT (authorized owner) is what actually starts the run with no
|
||||
approval gate (see issue #351). Running on the head branch puts the run's checks on the PR
|
||||
head SHA, so they satisfy branch protection's required checks."""
|
||||
if not head_ref:
|
||||
_log(f"::warning::PR #{pr_number} has no head branch — cannot dispatch; skipping.")
|
||||
return False
|
||||
ok = _gh_ok([
|
||||
"workflow", "run", _ci_workflow_file(), "--ref", head_ref,
|
||||
"-f", f"pr={pr_number}",
|
||||
"-f", f"head_sha={head_sha}",
|
||||
"-f", "reason=traffic-controller",
|
||||
])
|
||||
if ok:
|
||||
short = head_sha[:8] if head_sha else "?"
|
||||
_log(f" triggered CI for #{pr_number} on {head_ref} (head {short}).")
|
||||
return ok
|
||||
|
||||
|
||||
def run_trigger() -> int:
|
||||
"""The scheduler shell (companion `ci-trigger.yml`): pick the highest-priority needy
|
||||
PR(s) within the inflight cap and (re-)trigger their CI via workflow_dispatch; a P0
|
||||
emergency additionally preempts its strictly-lower runs. ALWAYS returns 0 — the scheduler
|
||||
must never wedge CI, and structurally it cannot: `ci.yml` keeps `on: pull_request`, so any
|
||||
human push (and a brand-new PR) still gets CI independently of this scheduler."""
|
||||
max_inflight = _positive_int("MAX_INFLIGHT_RUNS", 2)
|
||||
all_prs, needy, forks, meta = gather_trigger_snapshot()
|
||||
if not all_prs:
|
||||
_log("No open PRs (or could not list them) — nothing to trigger.")
|
||||
return 0
|
||||
|
||||
dec = select_triggers(all_prs, needy, max_inflight=max_inflight, forks=forks)
|
||||
_log(f"Open PRs: {len(all_prs)} | needy (absent/stale checks): {list(dec.needy_numbers)} "
|
||||
f"| inflight: {list(dec.inflight_numbers)} | cap {dec.max_inflight}, "
|
||||
f"free slots {dec.slots}.")
|
||||
if dec.skipped_fork_numbers:
|
||||
_log(f"Fork PR(s) needing CI left to `on: pull_request` (no token access — not "
|
||||
f"wedged): {list(dec.skipped_fork_numbers)}.")
|
||||
|
||||
for rid in dec.cancel_run_ids: # P0 emergency preemption
|
||||
if _cancel_run(rid):
|
||||
_log(f" P0 preemption: cancelled lower run {rid} (freed its runner).")
|
||||
|
||||
if not dec.trigger_numbers:
|
||||
_log("Nothing to trigger this pass (no needy PR fits a free slot).")
|
||||
return 0
|
||||
triggered = 0
|
||||
for number in dec.trigger_numbers:
|
||||
info = meta.get(number, {})
|
||||
if _dispatch_ci(number, info.get("headRefName", ""), info.get("headRefOid", "")):
|
||||
triggered += 1
|
||||
_log(f"Trigger pass complete — dispatched {triggered}/{len(dec.trigger_numbers)} "
|
||||
"run(s) in priority order.")
|
||||
return 0
|
||||
|
||||
|
||||
def _load_trigger_snapshot(
|
||||
text: str,
|
||||
) -> tuple[list[PullRequest], set[int], set[int], int]:
|
||||
data = json.loads(text)
|
||||
all_prs = [PullRequest.from_json(o) for o in data.get("prs", [])]
|
||||
needy = {int(n) for n in data.get("needy", [])}
|
||||
forks = {int(n) for n in data.get("forks", [])}
|
||||
max_inflight = int(data.get("max_inflight", 2))
|
||||
return all_prs, needy, forks, max_inflight
|
||||
|
||||
|
||||
def run_trigger_dry(text: str) -> int:
|
||||
all_prs, needy, forks, max_inflight = _load_trigger_snapshot(text)
|
||||
dec = select_triggers(all_prs, needy, max_inflight=max_inflight, forks=forks)
|
||||
_log(f"Trigger decision (cap {dec.max_inflight}, free slots {dec.slots}):")
|
||||
_log(f" inflight (occupying the cap): {list(dec.inflight_numbers)}")
|
||||
_log(f" needy candidates (priority order): {list(dec.needy_numbers)}")
|
||||
if dec.skipped_fork_numbers:
|
||||
_log(f" skipped forks (no token access): {list(dec.skipped_fork_numbers)}")
|
||||
if dec.cancel_run_ids:
|
||||
_log(f" P0 preemption — cancel run ids: {list(dec.cancel_run_ids)}")
|
||||
_log(f" => TRIGGER (in priority order): {list(dec.trigger_numbers)}")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
# Emit UTF-8 regardless of the host console so the log typography is stable on
|
||||
# the UTF-8 CI runners (and never raises on a legacy Windows code page).
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--mode", choices=("orchestrate", "trigger"), default="orchestrate",
|
||||
help="orchestrate (default): in-run runner-priority for the executing PR "
|
||||
"(unchanged). trigger: the scheduler that (re-)triggers CI by priority "
|
||||
"(issue #349).")
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true",
|
||||
help="read a snapshot JSON (from --input or stdin), print decisions, no network.")
|
||||
parser.add_argument(
|
||||
"--input", help="snapshot JSON file for --dry-run (default: stdin).")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.dry_run:
|
||||
text = (open(args.input, encoding="utf-8").read() if args.input
|
||||
else sys.stdin.read())
|
||||
return run_trigger_dry(text) if args.mode == "trigger" else run_dry(text)
|
||||
return run_trigger() if args.mode == "trigger" else run_live()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Exception as exc: # never let the controller fail CI
|
||||
_log(f"::warning::traffic_control crashed, proceeding fail-open: {exc}")
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,153 @@
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
|
||||
# GitHub Actions workflows
|
||||
|
||||
This directory holds the repo's workflows:
|
||||
|
||||
- **`ci.yml`** — the pull-request gate: build, unit tests, static analysis (ktlint /
|
||||
detekt), and the E2E/instrumented-test matrix, aggregated into one `CI passed` check
|
||||
that branch protection requires. It also runs the `traffic-control` job described
|
||||
below.
|
||||
- **`autoupdate.yml`** — rebases every open PR onto `main` whenever `main` advances, so
|
||||
the "branches up to date" branch rule never needs a manual update.
|
||||
- **`release.yml`** — turns a pushed version tag into signed, published release
|
||||
artifacts; see [`docs/release.md`](../../docs/release.md).
|
||||
|
||||
The rest of this README is about **`traffic-control`** — the job (in the Checks tab it
|
||||
shows up as **"Traffic control (runner priority)"**) that decides whose CI gets to run
|
||||
first when several PRs are queued at once.
|
||||
|
||||
## Why this job exists
|
||||
|
||||
GitHub Actions has no concept of "run this PR's checks before that one" — every PR's
|
||||
workflow run joins the same pool of runners and is served roughly first-come,
|
||||
first-served. That's fine most of the time, but with several PRs open at once it means
|
||||
an urgent one-line hotfix queues up as an equal to a routine refactor, and can end up
|
||||
stuck waiting behind CI runs for changes that aren't in any hurry.
|
||||
|
||||
`traffic-control` addresses that by reading a **priority label** on the current PR,
|
||||
comparing it against every other open PR, and then either freeing up a runner by
|
||||
cancelling a lower-priority PR's run (**preemption**), or briefly waiting before this
|
||||
PR's own heavy jobs start so a higher-priority PR's jobs get a head start
|
||||
(**hold-back**). It runs first in every PR's CI: every other job in `ci.yml`
|
||||
(`debug-build`, `unit-tests`, `static-analysis`, `e2e`, `e2e-preview`) declares
|
||||
`needs: traffic-control`, so it always goes first —
|
||||
|
||||
```
|
||||
PR's CI run starts
|
||||
│
|
||||
▼
|
||||
traffic-control
|
||||
│ 1. compute this PR's effective priority (see table below)
|
||||
│ 2. PASS 1 — preemption: cancel strictly-lower-priority OTHER PRs' active
|
||||
│ runs, but only if we're P0, or the target PR is `broken`
|
||||
│ 3. PASS 2 — hold-back: if we're not P0, wait (up to 180s) while any
|
||||
│ strictly-higher-priority OTHER PR still has an active run, then
|
||||
│ proceed regardless
|
||||
▼
|
||||
debug-build · unit-tests · static-analysis · e2e · e2e-preview
|
||||
```
|
||||
|
||||
## Effective priority
|
||||
|
||||
Priority comes from a label on the PR:
|
||||
|
||||
| Label | Effective priority | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `P0` | 0 (highest) | **Emergency only** — production is broken, or an emergency security fix. |
|
||||
| `P1` – `P9` | 1 – 9 | Higher number = lower priority. |
|
||||
| *(no `P` label)* | 5 (default) | Normal priority — most PRs. |
|
||||
| `broken` | 10 (lowest) | A stuck/failing PR, deprioritised below even `P9`. Overrides any `P0`–`P9` label also present. |
|
||||
|
||||
Apply at most one `P0`–`P9` label; if more than one is somehow present, the numerically
|
||||
lowest (most urgent) one wins. The `broken` label is meant to be applied by a maintainer
|
||||
to a PR whose CI is stuck or failing, as a "let everyone else go first while this gets
|
||||
fixed" signal — not something a PR author sets on their own work. Removing it restores
|
||||
whatever `P0`–`P9` priority (or the `P5` default) the PR would otherwise have.
|
||||
|
||||
## Preemption vs. holding back
|
||||
|
||||
### P0 preempts everyone lower
|
||||
|
||||
If *this* PR is `P0`, it's treated as an emergency: the job immediately cancels the
|
||||
in-progress or queued CI runs of **every other open PR at a strictly lower priority**
|
||||
(that is, anything that isn't also `P0`), freeing up their runners right away. A PR
|
||||
that gets cancelled this way isn't harmed long-term — it simply reruns on its next push,
|
||||
or the next time `autoupdate.yml` rebases it onto `main`. Because nothing outranks an
|
||||
emergency, a `P0` PR also never does the hold-back wait described below.
|
||||
|
||||
### A `broken` PR can be preempted by anyone
|
||||
|
||||
A PR labelled `broken` can't merge while it's broken, so its CI run occupying a runner
|
||||
is wasted capacity. Any PR that isn't itself `broken` — in other words, any PR with a
|
||||
real `P0`–`P9` priority — outranks it and may cancel its active run to reclaim the
|
||||
runner, not just a `P0` PR. `broken` is also the only priority level that yields to
|
||||
*everything*: since it sits below every other level, it always waits for other PRs'
|
||||
runs rather than the other way around.
|
||||
|
||||
### P1–P9 yield, but never cancel
|
||||
|
||||
Every other level (`P1`–`P9`, including the `P5` default) is cooperative rather than
|
||||
aggressive: it never cancels a run that's already going, no matter how much lower that
|
||||
run's priority is. Instead, before letting its own heavy jobs start, it checks whether
|
||||
any **strictly higher**-priority PR currently has an active or queued CI run. If so, it
|
||||
waits — polling every 15 seconds and re-checking the full list of open PRs each time, so
|
||||
a newly opened higher-priority PR is picked up mid-wait too — giving that PR's jobs a
|
||||
chance to reach the runner queue first. The wait is capped at **180 seconds**
|
||||
(comfortably inside the job's 6-minute hard timeout); once the budget runs out, this PR
|
||||
proceeds regardless. A PR should never be able to block itself indefinitely.
|
||||
|
||||
## Safety invariants
|
||||
|
||||
Whatever the priority math says, a few things are hard-coded to never happen:
|
||||
|
||||
- **Never touches `main` / push-triggered runs.** The job only acts on `pull_request`
|
||||
events, and every run it's even allowed to consider cancelling is filtered down to
|
||||
`event == pull_request` with `headBranch != main`.
|
||||
- **Never cancels this PR's own run.** The current PR is excluded from the "other PRs"
|
||||
list up front by PR number, and the currently-executing run ID is skipped too, just in
|
||||
case.
|
||||
- **Never cancels an equal-or-higher-priority run.** Only strictly-lower-priority PRs
|
||||
(a numerically larger, i.e. worse, priority) are ever candidates for cancellation.
|
||||
|
||||
## Honest limitation
|
||||
|
||||
This is a **best-effort head start, not a real priority queue.** GitHub Actions has no
|
||||
API for "give this run's jobs priority over that run's jobs" — runners are handed out
|
||||
roughly FIFO no matter what this job does. Hold-back approximates priority by making
|
||||
lower-priority PRs wait a little before their jobs even enter that FIFO queue, but under
|
||||
sustained contention (many PRs queuing at once) the bounded wait can run out before a
|
||||
higher-priority PR's jobs have actually made it through the runner pool. The waiting job
|
||||
itself is cheap and short-lived, which is exactly why the wait is capped rather than
|
||||
open-ended — occasionally under-prioritizing is preferable to a job that ties up a
|
||||
runner indefinitely just to wait.
|
||||
|
||||
## Not a merge gate
|
||||
|
||||
`traffic-control` is an optimizer, not a check your PR needs to pass. It's deliberately
|
||||
left out of `ci-passed`'s `needs:` list, every GitHub API call it makes is guarded
|
||||
against failure, the script always exits `0`, and the step itself runs with
|
||||
`continue-on-error: true`. A hiccup here — a transient API error, a missing permission,
|
||||
a fork PR without write access — can never fail or block your PR.
|
||||
|
||||
That said, the heavy jobs still order themselves after it via `needs: traffic-control`,
|
||||
so if this job were ever skipped or failed outright, GitHub would mark those jobs
|
||||
`skipped` — and `ci-passed` treats a required job coming back `skipped` as a gate
|
||||
failure. So the worst case is fail-safe: it blocks the merge rather than letting an
|
||||
untested PR through.
|
||||
|
||||
It also needs very little to run: no checkout step (it only calls the `gh` CLI), and
|
||||
just two permissions (`actions: write` to cancel runs, `pull-requests: read` to read
|
||||
labels). Values that come from outside the repo — labels, branch names — are only ever
|
||||
read through `gh`'s JSON output into shell variables, never interpolated as shell code.
|
||||
|
||||
## Where this is heading
|
||||
|
||||
**#342** is rewriting this logic as a tested Python module
|
||||
(`.github/scripts/traffic_control.py`), with a couple of small behavior refinements:
|
||||
draft PRs will also sink to the bottom (like `broken`), and PRs at the exact same
|
||||
priority level get an explicit order (whichever run is already in flight finishes
|
||||
first; among the rest, whoever has been waiting longest goes next). This README
|
||||
describes the shell-script version currently in `ci.yml` — see the comment block above
|
||||
the `traffic-control` job there for the byte-for-byte spec — and will be updated once
|
||||
#342 lands.
|
||||
@@ -6,13 +6,20 @@ name: Auto-update PR branches
|
||||
# touched (PR_FILTER: all) — this is no longer limited to PRs with GitHub auto-merge
|
||||
# enabled.
|
||||
#
|
||||
# IMPORTANT: for the branch update to RE-TRIGGER the PR's CI (so it can pass and merge),
|
||||
# this must run with a PAT, not the default GITHUB_TOKEN — pushes made by GITHUB_TOKEN do
|
||||
# not start new workflow runs (GitHub's anti-recursion rule), so the updated PR would sit
|
||||
# with stale checks. Create a fine-grained PAT scoped to this repo with
|
||||
# contents:read/write + pull-requests:read/write and add it as the AUTOUPDATE_TOKEN secret.
|
||||
# Without it this falls back to GITHUB_TOKEN, which updates the branch but will NOT re-run
|
||||
# the PR's checks.
|
||||
# IMPORTANT (issue #349): the branch update runs with the default GITHUB_TOKEN — ON PURPOSE.
|
||||
# A GITHUB_TOKEN push does NOT start new workflow runs (GitHub's anti-recursion rule), so
|
||||
# updating every behind PR here NO LONGER re-triggers every PR's CI. That deliberately breaks
|
||||
# the old merge-cascade (every merge -> autoupdate rebases all PRs with a PAT -> all re-run ->
|
||||
# ci.yml's cancel-in-progress kills each in-flight run -> PRs thrash and can't converge).
|
||||
# Branches still go up to date (satisfying "require branches up to date"); they just don't
|
||||
# auto-run CI on the new head SHA. Re-triggering that SHA's CI is now OWNED by the traffic-
|
||||
# controller scheduler (`.github/workflows/ci-trigger.yml` -> `traffic_control.py --mode
|
||||
# trigger`), which triggers the updated PRs deliberately, in priority order, a few at a time.
|
||||
# So this workflow must NOT use the PAT for the update push (that would re-introduce the
|
||||
# cascade). This workflow itself doesn't need AUTOUPDATE_TOKEN — but the secret is still REQUIRED
|
||||
# by the repo: the ci-trigger.yml scheduler dispatches CI with it (a GITHUB_TOKEN dispatch would
|
||||
# be held for manual approval and never run un-attended). Don't delete the secret. See
|
||||
# ci-trigger.yml + issue #351.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -38,7 +45,9 @@ jobs:
|
||||
- name: Update all behind PRs
|
||||
uses: chinthakagodawita/autoupdate@0707656cd062a3b0cf8fa9b2cda1d1404d74437e # v1.7.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.AUTOUPDATE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
# Default GITHUB_TOKEN — NOT a PAT — so this update push does not auto-retrigger CI
|
||||
# (anti-recursion). See the header: re-triggering is owned by ci-trigger.yml.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_FILTER: "all"
|
||||
PR_READY_STATE: "all"
|
||||
MERGE_CONFLICT_ACTION: "ignore"
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
name: CI trigger (traffic-controller)
|
||||
|
||||
# The traffic-controller SCHEDULER (issue #349). It OWNS CI *triggering*. After main advances,
|
||||
# autoupdate.yml updates every behind PR's branch with the built-in GITHUB_TOKEN which, by
|
||||
# GitHub's anti-recursion rule, does NOT start CI — so those PRs sit with absent/stale required
|
||||
# checks on their new head SHA and cannot merge. This workflow then (re-)triggers CI for the
|
||||
# highest-priority such PR(s), a few at a time (an inflight cap), in the existing P0–P9 /
|
||||
# broken-draft priority order — a poor-man's merge queue that replaces the old "every merge
|
||||
# re-runs every PR" thundering herd (the cascade; see the ci-merge-cascade note + issue #349).
|
||||
#
|
||||
# HOW IT TRIGGERS: `traffic_control.py --mode trigger` runs `gh workflow run ci.yml --ref
|
||||
# <pr-head-branch>`, dispatching with the AUTOUPDATE_TOKEN PAT — NOT the built-in GITHUB_TOKEN.
|
||||
# A workflow run triggered by GITHUB_TOKEN is held in the `action_required` state waiting on
|
||||
# MANUAL approval and never runs un-attended (confirmed empirically on #285 / #350: it sits
|
||||
# `action_required`, while the same dispatch by an authorized user runs immediately) — which
|
||||
# would defeat the whole scheduler. A PAT dispatch runs AS the authorized token owner, so the
|
||||
# run starts immediately with no approval gate (this is the original #349 design; #350's "no
|
||||
# PAT needed / workflow_dispatch is anti-recursion-exempt" claim was WRONG — see #351).
|
||||
# AUTOUPDATE_TOKEN is therefore REQUIRED for this scheduler. The dispatched run executes on the
|
||||
# PR's head branch, so its checks land on the PR head SHA and satisfy branch protection.
|
||||
#
|
||||
# WHEN IT RUNS:
|
||||
# • workflow_run, after "Auto-update PR branches" completes — the race-free moment: autoupdate
|
||||
# has finished moving branches to their new (checkless) head SHAs, so this pass sees exactly
|
||||
# the PRs that now need a run. (A bare `push: main` trigger would race autoupdate and often
|
||||
# read the pre-update SHAs, missing them until the next pass.)
|
||||
# • schedule (cron) — a backstop so no PR is ever permanently un-triggered even if a
|
||||
# workflow_run is missed/skipped (part of the fail-open guarantee), and so a brand-new PR
|
||||
# whose first `on: pull_request` run got cancelled is still picked up.
|
||||
# • workflow_dispatch — manual kick.
|
||||
#
|
||||
# FAIL-OPEN: the script guards every gh call and always exits 0; and structurally, ci.yml keeps
|
||||
# its `on: pull_request` trigger, so a human push (and a brand-new PR) always triggers CI
|
||||
# regardless of this scheduler — CI can never become permanently un-triggerable. Fork PRs (no
|
||||
# token/secret access) are skipped here and left to `on: pull_request`, so they are never wedged.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Auto-update PR branches"]
|
||||
types: [completed]
|
||||
schedule:
|
||||
# Backstop cadence (UTC). GitHub may delay scheduled runs under load; that is fine — this
|
||||
# is only a safety net behind the immediate workflow_run trigger above.
|
||||
- cron: "*/15 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
# Trigger-only; this workflow never gates a merge. The gh calls run as GH_TOKEN, which is
|
||||
# normally the AUTOUPDATE_TOKEN PAT (see the step below). These permissions govern the built-in
|
||||
# GITHUB_TOKEN, used only on the fail-open fallback path when AUTOUPDATE_TOKEN is absent:
|
||||
# `actions: write` lets it dispatch ci.yml (workflow_dispatch) and cancel strictly-lower runs
|
||||
# when a P0 emergency preempts; `pull-requests: read` + `contents: read` cover the PR/label
|
||||
# enumeration. (A GITHUB_TOKEN dispatch needs manual approval, so that fallback only actually
|
||||
# starts CI if repo settings don't gate GITHUB_TOKEN-triggered runs — the PAT is the real path.)
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
actions: write
|
||||
|
||||
# One trigger pass at a time. Do NOT cancel an in-flight pass (cancel-in-progress: false):
|
||||
# a half-finished pass could leave some needy PRs un-triggered until the next pass.
|
||||
concurrency:
|
||||
group: ci-trigger
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
trigger:
|
||||
name: Trigger CI by priority
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10 # generous backstop; the script only enumerates + dispatches, no waits
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.x"
|
||||
|
||||
# gh is auto-configured from GH_TOKEN / GH_REPO. The script guards every gh call and
|
||||
# always exits 0, so a hiccup (API error, missing permission, fork PR) can never wedge CI
|
||||
# — and even a total failure here leaves ci.yml's `on: pull_request` path intact.
|
||||
- name: Trigger CI for the highest-priority PR(s) needing a run
|
||||
env:
|
||||
# AUTOUPDATE_TOKEN (a PAT) is REQUIRED here: a CI run dispatched by the built-in
|
||||
# GITHUB_TOKEN is held for MANUAL approval (`action_required`) and never runs
|
||||
# un-attended, so the scheduler must dispatch AS the PAT's authorized owner to start
|
||||
# runs with no approval gate. `|| github.token` keeps this fail-open when the secret is
|
||||
# absent, but that GITHUB_TOKEN fallback only actually starts CI if repo settings don't
|
||||
# gate GITHUB_TOKEN-triggered runs — the PAT is the intended path (see #351).
|
||||
GH_TOKEN: ${{ secrets.AUTOUPDATE_TOKEN || github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
# Poor-man's merge-queue width: at most this many PRs run CI concurrently under the
|
||||
# scheduler (a P0 emergency bypasses this cap). Kept conservative because each PR
|
||||
# fans out to the whole E2E matrix (~8 API levels + preview); this is the main knob
|
||||
# to raise for throughput vs runner budget. The coordinator drives runner allocation.
|
||||
MAX_INFLIGHT_RUNS: "2"
|
||||
# The workflow file the scheduler enumerates runs for and dispatches.
|
||||
CI_WORKFLOW_FILE: "ci.yml"
|
||||
run: python3 .github/scripts/traffic_control.py --mode trigger
|
||||
+847
-330
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Extracted verbatim from .github/workflows/ci.yml (where it used to run first and gate the
|
||||
# heavy jobs via `needs: traffic-control`). It is being mothballed pending a rebuild as a
|
||||
# published GitHub Action and will be disabled after this merges; the heavy CI jobs no longer
|
||||
# depend on it. The decision core it drives (.github/scripts/traffic_control.py) is unchanged
|
||||
# and still unit-tested by the `traffic-control-tests` job in ci.yml.
|
||||
name: Traffic control (runner priority)
|
||||
|
||||
# The job reads github.event.pull_request.number, so it needs PR context.
|
||||
on: pull_request
|
||||
|
||||
jobs:
|
||||
# ── Priority-based runner orchestration ─────────────────────────────
|
||||
# Runs FIRST (the heavy jobs below all `needs: traffic-control`). It reads THIS
|
||||
# PR's P0–P9 label, `broken` label, and draft state to order runner access. The
|
||||
# decision logic lives in .github/scripts/traffic_control.py — a pure, unit-tested
|
||||
# core (see .github/scripts/test_traffic_control.py) plus a thin gh-I/O shell; this
|
||||
# step just checks out the repo and runs it.
|
||||
#
|
||||
# Effective priority: a `broken` OR `draft` PR => 10 (BOTTOM, below P9), overriding
|
||||
# any P0–P9; else the lowest-numbered P0–P9 label present (P0 = highest); else P5.
|
||||
#
|
||||
# • P0 = EMERGENCY ONLY (app broken in production / emergency security update).
|
||||
# P0 PREEMPTS: it cancels the in-progress / queued CI runs of ALL strictly-
|
||||
# LOWER-priority OTHER open PRs to grab their runners immediately. A preempted
|
||||
# PR simply re-runs on its next push / autoupdate rebase. P0 is the ONLY
|
||||
# priority that preempts a *normal* lower run — P1–P9 never bump those (a
|
||||
# higher PR may still reclaim a broken/draft lower run — see below).
|
||||
#
|
||||
# • P1–P9 = YIELD WITHOUT BUMPING a *normal* lower run. They do NOT cancel a
|
||||
# normal lower-priority run already going — a higher-priority PR does not evict
|
||||
# it, it just takes the next free slot (it MAY still reclaim a broken/draft
|
||||
# lower run — see below). Mechanism: a bounded hold-back. This job defers (up to
|
||||
# HOLD_BACK_BUDGET_SECONDS, kept well under timeout-minutes) while any strictly-
|
||||
# higher-priority OTHER open PR still has an active/queued CI run, and — within
|
||||
# its OWN priority level — while any peer is ordered ahead of it (an in-flight
|
||||
# run keeps its place; then oldest createdAt first). It proceeds the moment it
|
||||
# is at the front, or when the budget elapses (a PR never blocks itself).
|
||||
#
|
||||
# • `broken` / `draft` = BOTTOM (effective P10). Always yields, never preempts —
|
||||
# and because its run is wasted (a broken PR can't merge; a draft isn't merge-
|
||||
# ready), ANY higher-priority PR (not just P0) MAY cancel that run to reclaim
|
||||
# its runner (still the strictly-lower rule: broken/draft is the bottom, so any
|
||||
# ready PR outranks it). A maintainer marks a stuck/failing PR `broken` to drop
|
||||
# it below everything so others aren't blocked behind it AND may reclaim its
|
||||
# runner; a draft behaves the same until it is marked ready for review.
|
||||
#
|
||||
# Hard safety invariants, enforced in the script:
|
||||
# • never cancels a run on main / a push event (the gh query filters
|
||||
# --event pull_request and drops headBranch == main);
|
||||
# • never cancels THIS PR's own run (skips self by PR number + run id);
|
||||
# • never cancels an equal-or-higher-priority PR (only strictly-lower, prio > self);
|
||||
# • P1–P9 never bump a *normal* lower run (they only reclaim broken/draft) —
|
||||
# otherwise they just wait (bounded), then proceed.
|
||||
#
|
||||
# Honest limitation: GitHub Actions has no native priority queue and assigns
|
||||
# runners roughly FIFO, so the hold-back is a BEST-EFFORT head-start, not a hard
|
||||
# guarantee — under sustained contention the bounded wait can expire before a
|
||||
# higher-priority PR drains. The waiting job also occupies a (cheap, short-lived)
|
||||
# runner meanwhile, which is exactly why the wait is kept bounded.
|
||||
#
|
||||
# It is deliberately NOT a merge-gate check: it is absent from `ci-passed`'s
|
||||
# needs, every API call is guarded, the script always exits 0, and the step is
|
||||
# `continue-on-error` — so a hiccup (API error, missing permission, fork PR) can
|
||||
# never fail or block CI. The heavy jobs only *order* after it via `needs`; if it
|
||||
# were ever skipped/failed they'd be skipped, which `ci-passed` treats as a gate
|
||||
# failure (fail-safe: blocks merge, never spuriously passes).
|
||||
traffic-control:
|
||||
name: Traffic control (runner priority)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 6 # hard backstop; the P1–P9 hold-back budget stays well under this
|
||||
permissions:
|
||||
contents: read # check out .github/scripts/traffic_control.py
|
||||
actions: write # cancel lower-priority runs (P0 emergencies + broken/draft reclaim)
|
||||
pull-requests: read # read PR P0–P9 labels + draft state
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
# On a `pull_request` run this is the PR number and the script does its full in-run
|
||||
# runner-priority orchestration. On a scheduler `workflow_dispatch` run (issue #349)
|
||||
# the event is not `pull_request`, so the script no-ops here (`--mode orchestrate`
|
||||
# only acts on pull_request events) — priority was ALREADY applied at trigger time by
|
||||
# ci-trigger.yml, so re-doing the in-run hold-back would just waste runner time. The
|
||||
# `|| inputs.pr` keeps the number in the log for a dispatched run.
|
||||
SELF_PR: ${{ github.event.pull_request.number || inputs.pr }}
|
||||
# P1–P9 bounded hold-back knobs, read by traffic_control.py. BUDGET must stay
|
||||
# comfortably below timeout-minutes so the poll loop always exits 0 before the
|
||||
# hard job timeout fires — a timed-out job would skip the heavy jobs and fail
|
||||
# `ci-passed`.
|
||||
HOLD_BACK_BUDGET_SECONDS: "180"
|
||||
HOLD_BACK_POLL_SECONDS: "15"
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
# gh is auto-configured from GH_TOKEN / GH_REPO; python3 is preinstalled on the
|
||||
# runner. The script guards every API call and always exits 0 (belt-and-braces
|
||||
# with continue-on-error), so it can never fail or block CI.
|
||||
- name: Apply runner priority (P0/broken/draft preempt; P1–P9 hold back)
|
||||
continue-on-error: true
|
||||
run: python3 .github/scripts/traffic_control.py
|
||||
@@ -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/
|
||||
@@ -42,5 +44,9 @@ captures/
|
||||
# Kotlin
|
||||
.kotlin/
|
||||
|
||||
# Python (dev/CI helper scripts under .github/scripts, .claude/…)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Claude Code — personal settings (the shared settings.json IS committed)
|
||||
.claude/settings.local.json
|
||||
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# ============================================================================
|
||||
# Mergify configuration — PHASE 2: batched merge queue (issue #410).
|
||||
#
|
||||
# Spec: docs/ci/mergify-integration-spec.md + docs/ci/mergify.yml.proposed
|
||||
# (issues #407 / #408).
|
||||
# Schema: https://docs.mergify.com/configuration/file-format/
|
||||
# Verified against the LIVE Mergify docs (file-format, queue rules, priority,
|
||||
# merge-queue batches) on 2026-07-08 — the config format evolves, so this is
|
||||
# not from memory.
|
||||
#
|
||||
# HISTORY
|
||||
# Phase 1 (#409; landed #422, proven by #425/#426) ran a SERIAL queue — batch_size 1
|
||||
# + max_parallel_checks 1 + queue_conditions == merge_conditions — which kept GitHub's
|
||||
# "Require branches up to date before merging" checkbox LITERALLY on. It was proven
|
||||
# end-to-end: mergify[bot] auto-merged #425/#426, and serialised #426 -> #427 by
|
||||
# updating #427 onto the new `main` (incl. #426) and re-running CI before merging.
|
||||
# Phase 2 (this file, #410) turns on BATCHING now that the queue is proven.
|
||||
# ============================================================================
|
||||
#
|
||||
# WHAT THIS DOES
|
||||
# A BATCHED merge queue. Mergify takes up to `batch_size` queued PRs, builds ONE
|
||||
# speculative branch = (latest `main` + all the batched PRs), runs CI on that combined
|
||||
# branch ONCE, and — if green — merges the whole batch (each as a MERGE COMMIT) in
|
||||
# P0-P9 priority order. That is ~`batch_size`x the throughput of Phase-1 serial (one CI
|
||||
# cycle merges many PRs, not one) while STILL testing every PR against the latest `main`
|
||||
# (they all ride the same speculative batch branch).
|
||||
#
|
||||
# THE require-up-to-date SWAP (the one hard change from Phase 1 — do not misread it):
|
||||
# * Batching is INCOMPATIBLE with GitHub's "Require branches to be up to date before
|
||||
# merging" (docs.mergify.com/merge-queue/batches): a batch branch is by construction
|
||||
# "ahead of" its member PRs, so that per-PR linear check cannot pass. It is therefore
|
||||
# turned OFF in the `main` ruleset (18347032 -> `required_status_checks
|
||||
# .strict_required_status_checks_policy` = false). The required "CI passed" CHECK
|
||||
# itself STAYS required — only the "must be up to date" part is dropped.
|
||||
# * The INVARIANT that option protected — never merge code untested against the latest
|
||||
# `main` — is NOT lost; it MOVES to Mergify. The speculative batch branch IS
|
||||
# latest-`main`-plus-the-batch, so a green batch check IS the against-latest-main
|
||||
# test. This is the sanctioned swap (invariant preserved, enforcement relocated),
|
||||
# authorised ONLY because Phase 1 proved the queue actually performs that update+re-CI.
|
||||
# Do NOT drop require-up-to-date for any reason that does NOT relocate the invariant.
|
||||
# * The single required status check stays "CI passed" — the exact `name:` of the
|
||||
# `ci-passed` job in .github/workflows/ci.yml. NOT "ci-passed".
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# queue_rules — how a batch of queued PRs is validated and merged.
|
||||
# ---------------------------------------------------------------------------
|
||||
queue_rules:
|
||||
- name: default
|
||||
# Merge a batch ONLY when the combined batch branch is green on the single required
|
||||
# context ("CI passed"), every member PR targets `main`, is not a draft, has no merge
|
||||
# conflict, and is not flagged `broken`. NOTE: the ruleset requires 0 approvals
|
||||
# (required_approving_review_count = 0), so there is deliberately NO `#approved-reviews-by`
|
||||
# condition — it would wedge the solo-maintainer flow (nobody can approve their own PR).
|
||||
#
|
||||
# queue_conditions (queue ENTRY) are kept IDENTICAL — same conditions, same order — to
|
||||
# merge_conditions (MERGE). Under Phase 1 this identity was REQUIRED for in-place-checks
|
||||
# compatibility with the strict ruleset; with require-up-to-date now off, batching uses
|
||||
# speculative batch checks and the identity is no longer mandatory — but it is kept so
|
||||
# auto_merge_conditions / queue_conditions / merge_conditions remain one single source of
|
||||
# truth (no reason for entry and merge gates to differ). Keep all three lists identical.
|
||||
queue_conditions:
|
||||
- base = main
|
||||
- -draft
|
||||
- -conflict
|
||||
- label != broken
|
||||
- check-success = CI passed
|
||||
merge_conditions:
|
||||
- base = main
|
||||
- -draft
|
||||
- -conflict
|
||||
- label != broken
|
||||
- check-success = CI passed
|
||||
# BATCHING (Phase 2 / #410): validate up to 5 PRs together on ONE speculative branch, so
|
||||
# a single ~15-min CI cycle can merge up to 5 PRs instead of 1. `batch_max_wait_time`
|
||||
# bounds how long Mergify waits to fill a batch before starting CI on a partial one, so a
|
||||
# lone PR is not left waiting for companions. Requires the require-up-to-date checkbox OFF
|
||||
# (see the SWAP note in the header).
|
||||
batch_size: 5
|
||||
batch_max_wait_time: 5 min
|
||||
# Merge commit — never squash / rebase / fast-forward (repo policy: merges use merge
|
||||
# commits, never squash).
|
||||
merge_method: merge
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# merge_queue — queue-wide options.
|
||||
# ---------------------------------------------------------------------------
|
||||
merge_queue:
|
||||
# One batch validated at a time. `batch_size` (above) — not parallelism — is the Phase-2
|
||||
# throughput lever: a single batch of up to 5 PRs merges per CI cycle, keeping the
|
||||
# expensive/wedge-prone ~15-min E2E matrix to ONE concurrent run. Raising this would run
|
||||
# multiple batches' CI concurrently (more runner load / cost) — a later tuning knob, not
|
||||
# needed to get the batching win.
|
||||
max_parallel_checks: 1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# priority_rules — map the repo's P0-P9 labels onto queue priority.
|
||||
# Higher number merges first (Mergify keywords: low=1000 / medium=2000 / high=3000;
|
||||
# numeric range 1-10000). P0 is emergency-only and outranks everything. PRs with no P-label
|
||||
# fall to Mergify's default `medium` (2000). Priority also orders merges within a batch.
|
||||
# ---------------------------------------------------------------------------
|
||||
priority_rules:
|
||||
- name: p0-emergency
|
||||
conditions:
|
||||
- label = P0
|
||||
priority: 10000
|
||||
allow_checks_interruption: true
|
||||
- name: p1
|
||||
conditions:
|
||||
- label = P1
|
||||
priority: 9000
|
||||
allow_checks_interruption: true
|
||||
- name: p2
|
||||
conditions:
|
||||
- label = P2
|
||||
priority: 8000
|
||||
allow_checks_interruption: true
|
||||
- name: p3
|
||||
conditions:
|
||||
- label = P3
|
||||
priority: 7000
|
||||
allow_checks_interruption: true
|
||||
- name: p4
|
||||
conditions:
|
||||
- label = P4
|
||||
priority: 6000
|
||||
allow_checks_interruption: true
|
||||
- name: p5
|
||||
conditions:
|
||||
- label = P5
|
||||
priority: 5000
|
||||
allow_checks_interruption: true
|
||||
- name: p6
|
||||
conditions:
|
||||
- label = P6
|
||||
priority: 4000
|
||||
allow_checks_interruption: true
|
||||
- name: p7
|
||||
conditions:
|
||||
- label = P7
|
||||
priority: 3000
|
||||
allow_checks_interruption: true
|
||||
- name: p8
|
||||
conditions:
|
||||
- label = P8
|
||||
priority: 2000
|
||||
- name: p9
|
||||
conditions:
|
||||
- label = P9
|
||||
priority: 1000
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# merge_protections_settings — WHICH PRs are AUTOMATICALLY added to the queue.
|
||||
# (Unchanged from Phase 1 — this is the auto-queue TRIGGER, orthogonal to batching.)
|
||||
#
|
||||
# Automatic queueing lives in `auto_merge_conditions` (the old `pull_request_rules` queue
|
||||
# action no longer auto-queues — deprecated 2026-07-16). Same audience as before: green on
|
||||
# "CI passed", targeting `main`, not a draft, no conflicts, not `broken`. A matched PR is
|
||||
# auto-QUEUED (not merged directly); the batched queue then routes + merges it.
|
||||
#
|
||||
# Kept IDENTICAL (same conditions, same order) to queue_conditions / merge_conditions above.
|
||||
# ---------------------------------------------------------------------------
|
||||
merge_protections_settings:
|
||||
auto_merge_conditions:
|
||||
- base = main
|
||||
- -draft
|
||||
- -conflict
|
||||
- label != broken
|
||||
- check-success = CI passed
|
||||
@@ -27,24 +27,38 @@ or via Gradle Managed Devices `./gradlew e2eGroupDebugAndroidTest` (whole matrix
|
||||
`app/build.gradle.kts` must stay in lockstep with the E2E matrix in `.github/workflows/ci.yml`.
|
||||
|
||||
**Before treating a change as done**, run the fast CI gate: `assembleDebug` +
|
||||
`testDebugUnitTest` + `compileDebugAndroidTestKotlin` + `lintDebug` + `ktlintCheck` +
|
||||
`detekt` + the top-of-matrix emulator E2E `api35DebugAndroidTest` + `api36DebugAndroidTest` +
|
||||
the API 37 preview E2E via `python3 .claude/skills/preflight/api37_e2e.py` (the `/preflight`
|
||||
skill does all of this). `compileDebugAndroidTestKotlin` compiles the `androidTest` source set
|
||||
`testDebugUnitTest` + `jacocoTestCoverageVerification` + `compileDebugAndroidTestKotlin` +
|
||||
`lintDebug` + `ktlintCheck` + `detekt` + the local emulator E2E — the instrumented test class(es)
|
||||
you changed via `python3 .claude/skills/preflight/local_instrumented.py <classes>` + the API 37
|
||||
preview E2E via `python3 .claude/skills/preflight/api37_e2e.py` (the `/preflight` skill does all
|
||||
of this). `jacocoTestCoverageVerification` runs right after `testDebugUnitTest` (it reads that
|
||||
task's JVM exec data) and enforces the whole-app **no-regression line-coverage floor (currently
|
||||
0.84)**, catching coverage regressions locally instead of only in CI (the exact class of failure
|
||||
that reached CI on #367). `compileDebugAndroidTestKotlin` compiles the `androidTest` source set
|
||||
that the static part of the gate skips, catching E2E/instrumented-test compile errors before
|
||||
they surface only in CI. `ktlintCheck`/`detekt` cover the `test`/`androidTest` source sets that
|
||||
`lintDebug` skips, so they catch style violations that would otherwise fail CI's Static analysis
|
||||
gate. `api35DebugAndroidTest` and `api36DebugAndroidTest` run the instrumented/E2E suite on the
|
||||
top two stable API levels in the E2E matrix, each via its own Gradle Managed Device (Gradle boots
|
||||
and tears down each emulator automatically). API 37 (preview) has no Gradle Managed Device — its
|
||||
gate. The local E2E does **not** use Gradle Managed Devices (`apiXXDebugAndroidTest`): GMD's
|
||||
snapshot step fails locally under the AEHD 2.2 hypervisor. Instead `local_instrumented.py`
|
||||
cold-boots one existing AVD by hand (no GMD, no snapshot) and runs `connectedDebugAndroidTest`
|
||||
filtered to the class(es) you pass — run the ones you changed; the full ~114-test suite wedges
|
||||
mid-run locally. API 37 (preview) has no Gradle Managed Device — its
|
||||
only image is the nonstandard `android-37.0` / `google_apis_ps16k` pairing (see the comment above
|
||||
`testOptions.managedDevices` in `app/build.gradle.kts`) — so `api37_e2e.py` hand-provisions it,
|
||||
mirroring CI's `e2e-preview` job (same image + emulator flags, except it uses host-GPU
|
||||
`-gpu auto-no-window` locally vs CI's headless `-gpu swiftshader_indirect`), boots it headless,
|
||||
runs `connectedDebugAndroidTest`, and tears it down. Running all three levels locally is required; the
|
||||
rest of the multi-API matrix (API 29–34) stays CI's job. Emulators need a free hardware
|
||||
runs `connectedDebugAndroidTest`, and tears it down. Both local E2E steps are required; the
|
||||
full multi-API matrix (API 29–37) stays CI's job. Emulators need a free hardware
|
||||
hypervisor (VT-x/WHPX), so shut down VirtualBox/other VMs first or the AVD hangs at 0% CPU.
|
||||
|
||||
**Dev scripts: prefer Python (stdlib).** Auxiliary dev / CI-helper scripts — like the preflight
|
||||
E2E runners (`.claude/skills/preflight/local_instrumented.py`, `api37_e2e.py`) and
|
||||
`.claude/hooks/check-spdx.py` — are written in **Python 3, standard library only**, for
|
||||
cross-platform portability. The primary dev box is Windows, where bash-only helpers need Git Bash
|
||||
and hit gaps (`jq` missing, `taskkill` vs `kill`, path/quoting). **Do not add new bash-only
|
||||
(`.sh`) or PowerShell-only dev scripts**; write new helpers in Python (or extend the existing
|
||||
ones). Scope is auxiliary tooling only — product code stays Kotlin and Gradle stays Kotlin DSL.
|
||||
|
||||
## Build-config gotchas
|
||||
|
||||
- **Built-in Kotlin (AGP 9.x).** Kotlin compilation is handled by AGP's built-in Kotlin;
|
||||
@@ -77,11 +91,21 @@ pulled in as a real dependency for unit tests because `android.jar`'s version is
|
||||
A change is not done until it ships with passing **unit tests** and **E2E/instrumented tests**
|
||||
that exercise the new or changed behaviour. Writing and committing that E2E/instrumented test
|
||||
is a required part of every task — and the test must actually **run and pass**, not merely
|
||||
compile: preflight runs the top-of-matrix emulator E2E locally — `api35DebugAndroidTest` +
|
||||
`api36DebugAndroidTest` (Gradle Managed Devices) plus the API 37 preview via
|
||||
`api37_e2e.py` (hand-provisioned, mirroring CI's `e2e-preview` job) — and all three must be
|
||||
green before the change is done. CI then runs the full multi-API matrix plus the API 37 preview
|
||||
job.
|
||||
compile: preflight runs the changed instrumented test class(es) on a locally cold-booted
|
||||
emulator via `local_instrumented.py` (no Gradle Managed Devices — they fail locally) plus the
|
||||
API 37 preview via `api37_e2e.py` (hand-provisioned, mirroring CI's `e2e-preview` job) — and both
|
||||
must be green before the change is done. CI then runs the full multi-API matrix plus the API 37
|
||||
preview job.
|
||||
|
||||
No **app source-code** change is complete without **appropriate logging** added at its key
|
||||
points — lifecycle transitions, error/fallback paths, significant state changes — so behaviour is
|
||||
diagnosable from a user's debug report. Log through the `AppLog` facade
|
||||
(`org.libremail.reporting.AppLog`), which mirrors to Logcat **and** the in-memory
|
||||
`RingLogBuffer` that feeds a `DebugReport` — never raw `android.util.Log` (a detekt guard forbids
|
||||
it). Logging must be **PII-free**: never log emails, server hosts, message content, or
|
||||
credentials — use `accountLogRef(account.id)` for account references; throwables passed to
|
||||
`AppLog` are auto-scrubbed. This applies to app source changes; pure test/config/doc changes
|
||||
don't need new logging.
|
||||
|
||||
## Repo etiquette
|
||||
|
||||
|
||||
+289
-57
@@ -1,4 +1,6 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import org.gradle.testing.jacoco.plugins.JacocoTaskExtension
|
||||
import org.gradle.testing.jacoco.tasks.JacocoCoverageVerification
|
||||
import org.gradle.testing.jacoco.tasks.JacocoReport
|
||||
import java.util.Properties
|
||||
|
||||
@@ -64,6 +66,12 @@ android {
|
||||
buildConfigField("String", "OUTLOOK_OAUTH_CLIENT_ID", "\"$outlookOAuthClientId\"")
|
||||
buildConfigField("String", "OUTLOOK_OAUTH_REDIRECT_URI", "\"$outlookRedirectScheme://oauth2redirect\"")
|
||||
buildConfigField("String", "DEBUG_REPORT_ENDPOINT", "\"$debugReportEndpoint\"")
|
||||
// IMAP connection reuse (issue #357 Part 2, wiring the #125 spike): keep one authenticated
|
||||
// IMAP connection warm per account instead of paying a cold CONNECT+TLS+LOGIN on every
|
||||
// operation — the fix for Gmail throttling LibreMail's connect-per-operation traffic. ON by
|
||||
// default; this is the safety switch: flip to "false" here (a build-config change, no Kotlin
|
||||
// edit) to fall back to connect-per-operation if a server misbehaves with a kept-alive socket.
|
||||
buildConfigField("Boolean", "IMAP_CONNECTION_REUSE", "true")
|
||||
// AppAuth's bundled manifest requires this placeholder; it registers the redirect scheme on
|
||||
// RedirectUriReceiverActivity so the Outlook sign-in redirect returns to the app.
|
||||
manifestPlaceholders["appAuthRedirectScheme"] = outlookRedirectScheme
|
||||
@@ -140,6 +148,12 @@ android {
|
||||
}
|
||||
|
||||
testOptions {
|
||||
// Robolectric-backed Compose UI unit tests (issue #373) need the merged Android resources
|
||||
// (drawables, strings, the compiled resource table) on the JVM unit-test classpath so
|
||||
// `stringResource(...)` and Material3 theming resolve without an emulator. Off by default in
|
||||
// AGP; JVM tests that don't touch resources are unaffected.
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
|
||||
// Gradle Managed Devices define the per-API E2E matrix as config-as-code: one virtual
|
||||
// device per supported Android version (a rolling ~7-year window, API 29 → latest stable).
|
||||
// Run the whole matrix with `./gradlew e2eGroupDebugAndroidTest`, or one level with e.g.
|
||||
@@ -199,10 +213,181 @@ jacoco {
|
||||
toolVersion = libs.versions.jacoco.get()
|
||||
}
|
||||
|
||||
// Unit-test coverage report (issue #192). Reads the exec data the base `jacoco` plugin records for
|
||||
// the JVM `testDebugUnitTest` task, mapped against the debug variant's compiled Kotlin classes and
|
||||
// the hand-written main sources. Produces machine-readable XML + human-readable HTML under
|
||||
// build/reports/jacoco/jacocoTestReport/. Instrumented/E2E coverage is out of scope (issue #192).
|
||||
// The Robolectric-backed JVM Compose UI tests (#373) load the classes-under-test through
|
||||
// Robolectric's sandbox classloader, which presents them to the JaCoCo agent WITHOUT a code-source
|
||||
// location. JaCoCo skips no-location classes by default, so on-the-fly coverage for every composable
|
||||
// exercised only by a Robolectric test would silently record as zero — the file would be removed
|
||||
// from `jacocoNonJvmTestableSurface` yet contribute nothing but missed lines, dragging the bundle
|
||||
// ratio DOWN instead of up. `isIncludeNoLocationClasses = true` makes the agent keep that coverage;
|
||||
// `jdk.internal.*` is excluded because instrumenting those JDK classes breaks under JDK 17+.
|
||||
tasks.withType<Test>().configureEach {
|
||||
configure<JacocoTaskExtension> {
|
||||
isIncludeNoLocationClasses = true
|
||||
excludes = listOf("jdk.internal.*")
|
||||
}
|
||||
}
|
||||
|
||||
// Unit-test coverage (issue #192). Two tasks share ONE scoping so they can never measure different
|
||||
// surfaces: `jacocoTestReport` (XML+HTML under build/reports/jacoco/jacocoTestReport/) and
|
||||
// `jacocoTestCoverageVerification` (the no-regression gate, further down). Both read the exec data
|
||||
// the base `jacoco` plugin records for the JVM `testDebugUnitTest` task, mapped against the debug
|
||||
// variant's compiled Kotlin classes and the hand-written main sources. Instrumented/E2E coverage is
|
||||
// out of scope (issue #192).
|
||||
|
||||
// Strip generated code from the denominator so the % reflects hand-written Kotlin. Verified
|
||||
// against an actual compileDebugKotlin output tree: Room's KSP-generated `_Impl` DAOs/database
|
||||
// and the Compose compiler's per-file ComposableSingletons holders are the only generated code
|
||||
// that actually lands in classDirectories below (Room's KSP output is added as an extra Kotlin
|
||||
// source root on the *same* compile task, so it comes out the same door as hand-written code).
|
||||
// Hilt/Dagger's generated Java (Hilt_*, Dagger*_HiltComponents*, *_GeneratedInjector, *_Factory,
|
||||
// *_MembersInjector, hilt_aggregated_deps) and AGP's BuildConfig/R/Manifest are compiled by a
|
||||
// separate javac task (hiltJavaCompileDebug / compileDebugJavaWithJavac) into a directory this
|
||||
// report never reads, so those patterns are conventional belt-and-suspenders in case that ever
|
||||
// changes. DataBinding isn't enabled in this module (no buildFeatures.dataBinding/viewBinding),
|
||||
// so there's nothing generated for it to exclude; if it's turned on later, add "**/BR.class",
|
||||
// "**/DataBinderMapperImpl*.class" and "**/*Binding.class".
|
||||
//
|
||||
// Deliberately NOT excluded: Kotlin's own `$$inlined$` synthetic classes (e.g. for
|
||||
// `Flow.map { ... }` in the repositories) — those hold real hand-written transform logic, not
|
||||
// generated boilerplate, so stripping them would silently shrink the measured surface.
|
||||
val jacocoGeneratedExcludes = listOf(
|
||||
"**/R.class",
|
||||
"**/R\$*.class",
|
||||
"**/BuildConfig.*",
|
||||
"**/Manifest*.*",
|
||||
"**/Hilt_*.class",
|
||||
"**/Dagger*.class",
|
||||
"**/*_Hilt*",
|
||||
"**/*_GeneratedInjector.class",
|
||||
"**/hilt_aggregated_deps/**",
|
||||
"**/dagger/**",
|
||||
"**/*_Factory*",
|
||||
"**/*_MembersInjector*",
|
||||
"**/*_Provide*",
|
||||
"**/*_Impl*",
|
||||
"**/ComposableSingletons*",
|
||||
)
|
||||
|
||||
// Scope the denominator to the JVM-testable surface (issues #290/#292, following the Phase-2 coverage
|
||||
// audit): unlike `jacocoGeneratedExcludes` above, none of this is generated code — it is hand-written
|
||||
// but structurally unreachable from a JVM unit test, so counting it against the metric just measures
|
||||
// how much Compose/framework glue exists rather than how well the logic is tested. Four buckets:
|
||||
// 1. Compose screen/component render code. Historically only exercisable via an emulator, so it was
|
||||
// excluded here. Issue #373 changes that: Robolectric runs the Android framework on the JVM, so a
|
||||
// `createComposeRule()` test in the `test` source set now gives these files real JVM coverage
|
||||
// without an emulator. This bucket therefore SHRINKS one screen at a time — each glob is deleted
|
||||
// in the same PR that adds that screen's Robolectric JVM Compose test. AddAnotherAccountScreen was
|
||||
// the first (see AddAnotherAccountScreenJvmTest) and has been removed below; the rest are tracked
|
||||
// as per-area conversion tickets under #373. The coverage-floor re-ratchet is deferred until the
|
||||
// whole conversion is done and stable (#373) — do NOT raise it in a conversion PR.
|
||||
// 2. Android framework entry points the OS instantiates directly (Activity/Service/Application/
|
||||
// BackupAgent) rather than the app's own code constructing them.
|
||||
// 3. Hilt DI modules — `@Provides`/`@Binds` one-liners with no branching logic.
|
||||
// 4. The `src/debug` cold-open probe (issue #221), a `ContentProvider` that only runs in a forked
|
||||
// instrumented process (see its kdoc) and is never packaged in a release build anyway.
|
||||
//
|
||||
// KEPT IN SCOPE — this corrects #292, which excluded `**/*Worker*`: the six WorkManager workers
|
||||
// (SyncWorker, BackfillWorker, PruneWorker, SendWorker, ReportPurgeWorker, ReportUploadWorker) are
|
||||
// all directly unit-tested today (construct-the-worker-and-call-doWork(), e.g. SyncWorkerTest,
|
||||
// SendWorkerTest), so they carry real tested logic and belong in BOTH the numerator and denominator.
|
||||
// Only their Hilt wiring (WorkManagerModule) is excluded, and that falls under `**/di/**` below — so
|
||||
// there is intentionally no `**/*Worker*` glob in the list.
|
||||
//
|
||||
// Also deliberately NOT excluded, even though each sits in a package/pattern above and renders UI:
|
||||
// files that carry plain, unit-tested logic alongside their `@Composable` functions. JaCoCo has no
|
||||
// finer granularity than a class file, and Kotlin compiles every top-level function in a .kt file —
|
||||
// `@Composable` or not — into the SAME facade class (`<File>Kt.class`); excluding that class would
|
||||
// silently zero out the tested function's coverage too, not just the render code's. Confirmed
|
||||
// against these files' own dedicated tests before leaving them out of the list below:
|
||||
// - ui/compose/RichTextEditor.kt (RichTextEditorTest) — the AnnotatedString<->RichTextContent
|
||||
// editor-op functions (applyStyle/applyBlock/applyLink/toRichContent/toAnnotatedString/...).
|
||||
// - ui/settings/AccountReorderList.kt (AccountReorderListTest) — commitDrag's reorder maths.
|
||||
// - ui/reader/HtmlBody.kt (HtmlBodyTest, InlineImageResolverTest) — cidKey/resolveInlineImage/
|
||||
// wrapHtml/toCssHex.
|
||||
// - ui/reporting/ReportReviewScreen.kt (ReportReviewClipboardTest) — copyReportPayloadToClipboard.
|
||||
// (ui/compose/format/FontRegistry.kt and ui/mailbox/FolderLabels.kt are plain logic files with no
|
||||
// `@Composable` at all — never at risk — but sit right next to excluded files below.) For the same
|
||||
// reason this list names each Screen/component file individually rather than a package-wide
|
||||
// "**/ui/**": a blanket pattern can't carve the four files above back out, and would also reach
|
||||
// every `*ViewModel*`.
|
||||
val jacocoNonJvmTestableSurface = listOf(
|
||||
// --- Compose UI render code: one glob per screen/component file (see the exceptions above) ---
|
||||
// LibreMailApp KEPT excluded (#384, the acceptable exception): the composable is a real NavHost whose
|
||||
// non-onboarding start destinations call hiltViewModel(), and standing the graph up needs owners a
|
||||
// plain JVM compose rule can't surface — so graph-level nav stays on the instrumented OnboardingFlowTest.
|
||||
// Its JVM-tractable parts (LibreMailBottomBar, StartupCrashPrompt, the cold-start hold guards) ARE
|
||||
// exercised by LibreMailAppJvmTest, but the file's compiled facade (LibreMailAppKt) stays excluded.
|
||||
"**/LibreMailApp*",
|
||||
// AccountPickerScreen, AppPasswordSetupScreen & ManualSetupScreen converted to Robolectric JVM
|
||||
// Compose tests (#378) — now JVM-covered.
|
||||
// ComposeScreen (the email editor) converted to a Robolectric JVM Compose test (#382) — now
|
||||
// JVM-covered.
|
||||
// ColorSwatch(Row), FontPicker, FontSizePicker & ParagraphAlignmentControl converted to
|
||||
// Robolectric JVM Compose tests (#376) — now JVM-covered.
|
||||
// DraftsScreen, OutboxScreen & ProblemReportsScreen converted to Robolectric JVM Compose tests
|
||||
// (#379) — now JVM-covered.
|
||||
// LockScreen converted to a Robolectric JVM Compose test (#377) — now JVM-covered.
|
||||
// AppLockGateHost converted to a Robolectric JVM Compose test (#384) — now JVM-covered.
|
||||
// FolderDrawer & MailboxScreen (the Paging 3 mailbox list + folder drawer) converted to
|
||||
// Robolectric JVM Compose tests (#383) — now JVM-covered.
|
||||
// AddAnotherAccountScreen (#373) plus the onboarding welcome/license and contacts/battery steps
|
||||
// (#377) converted to Robolectric JVM Compose tests — now JVM-covered.
|
||||
// ReaderScreen converted to a Robolectric JVM Compose test (#381) — now JVM-covered. Its HTML body
|
||||
// renders through HtmlBody, a hardened WebView that Robolectric can only present as a non-rendering
|
||||
// shadow, so ReaderScreenJvmTest asserts the chrome (top bar, star/delete/reply actions, attachment
|
||||
// accordion) and the loading/plain-text/empty/error/remote-images-banner branches — never the
|
||||
// WebView's rendered HTML. HtmlBody.kt stays in scope covered by HtmlBodyTest/InlineImageResolverTest.
|
||||
// SettingsScreen (+ ContactAutocompleteRow), AccountSettingsScreen, SettingsComponents (SwitchRow/
|
||||
// ClickRow/RadioRow/RetentionSection), SignaturesScreen & SignatureEditScreen converted to
|
||||
// Robolectric JVM Compose tests (#380) — now JVM-covered.
|
||||
// CacheEncryptionGate.kt (issue #359/#367 fail-closed encryption gate) is pure render: the gate
|
||||
// composable, its blank cover, the error screen, and the ephemeral report-review screen — no plain
|
||||
// top-level logic. Spelled out to "...GateKt*" (the file's compiled facade class), NOT the bare
|
||||
// "**/CacheEncryptionGate*" this list otherwise uses, because unlike every Screen/ViewModel pair
|
||||
// above, CacheEncryptionGateViewModel's name literally starts with "CacheEncryptionGate" — a bare
|
||||
// wildcard would also swallow the (94%-covered, dedicated-tested) ViewModel and its sealed
|
||||
// CacheEncryptionGateState. CacheEncryptionGateViewModel and CacheEncryptionUnavailableException
|
||||
// stay in scope (both have JVM tests: CacheEncryptionGateViewModelTest, DatabaseProvisionerTest).
|
||||
"**/CacheEncryptionGateKt*",
|
||||
// --- Android framework entry points (OS-instantiated). NB: Workers are intentionally NOT here
|
||||
// --- (they are unit-tested — see the KEPT IN SCOPE note above).
|
||||
"**/*Activity*",
|
||||
"**/*Service*",
|
||||
"**/LibreMailApplication*",
|
||||
"**/*BackupAgent*",
|
||||
// --- Hilt DI wiring (includes WorkManagerModule) ---
|
||||
"**/di/**",
|
||||
// --- src/debug cold-open probe (issue #221) ---
|
||||
"**/data/local/coldopen/**",
|
||||
// --- src/debug fetch-gate receiver (issue #393): a BroadcastReceiver that only runs on-device
|
||||
// --- (adb-driven), covered by an instrumented test, never packaged into a release build. Its
|
||||
// --- pure collaborators DebugFetchGate/FetchScope stay IN scope (unit-tested by DebugFetchGateTest).
|
||||
"**/debug/FetchGateReceiver*",
|
||||
)
|
||||
|
||||
// Classes = the debug variant's compiled Kotlin (AGP 9 built-in Kotlin output), with the generated
|
||||
// code and the non-JVM-testable surface above stripped out. All hand-written code here is Kotlin, so
|
||||
// the javac output (purely Hilt/Dagger/BuildConfig generated) is omitted. Hoisted to a shared val so
|
||||
// the report and the verification gate always run against the identical denominator.
|
||||
val jacocoDebugKotlinClasses = layout.buildDirectory.dir(
|
||||
"intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes",
|
||||
)
|
||||
val jacocoClassDirectories = fileTree(jacocoDebugKotlinClasses) {
|
||||
exclude(jacocoGeneratedExcludes + jacocoNonJvmTestableSurface)
|
||||
}
|
||||
|
||||
// Sources = hand-written main Kotlin.
|
||||
val jacocoSourceDirectories = files("src/main/kotlin")
|
||||
|
||||
// Exec data written by the instrumented testDebugUnitTest task. Accept the base `jacoco` plugin's
|
||||
// default location and AGP's enableUnitTestCoverage location so the wiring is robust either way.
|
||||
val jacocoExecutionData = fileTree(layout.buildDirectory) {
|
||||
include(
|
||||
"jacoco/testDebugUnitTest.exec",
|
||||
"outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec",
|
||||
)
|
||||
}
|
||||
|
||||
tasks.register<JacocoReport>("jacocoTestReport") {
|
||||
// Ensure the unit tests (and thus their coverage exec data) have run first.
|
||||
dependsOn("testDebugUnitTest")
|
||||
@@ -214,62 +399,91 @@ tasks.register<JacocoReport>("jacocoTestReport") {
|
||||
html.required.set(true)
|
||||
}
|
||||
|
||||
// Strip generated code from the denominator so the % reflects hand-written Kotlin. Verified
|
||||
// against an actual compileDebugKotlin output tree: Room's KSP-generated `_Impl` DAOs/database
|
||||
// and the Compose compiler's per-file ComposableSingletons holders are the only generated code
|
||||
// that actually lands in classDirectories below (Room's KSP output is added as an extra Kotlin
|
||||
// source root on the *same* compile task, so it comes out the same door as hand-written code).
|
||||
// Hilt/Dagger's generated Java (Hilt_*, Dagger*_HiltComponents*, *_GeneratedInjector, *_Factory,
|
||||
// *_MembersInjector, hilt_aggregated_deps) and AGP's BuildConfig/R/Manifest are compiled by a
|
||||
// separate javac task (hiltJavaCompileDebug / compileDebugJavaWithJavac) into a directory this
|
||||
// report never reads, so those patterns are conventional belt-and-suspenders in case that ever
|
||||
// changes. DataBinding isn't enabled in this module (no buildFeatures.dataBinding/viewBinding),
|
||||
// so there's nothing generated for it to exclude; if it's turned on later, add "**/BR.class",
|
||||
// "**/DataBinderMapperImpl*.class" and "**/*Binding.class".
|
||||
//
|
||||
// Deliberately NOT excluded: Kotlin's own `$$inlined$` synthetic classes (e.g. for
|
||||
// `Flow.map { ... }` in the repositories) — those hold real hand-written transform logic, not
|
||||
// generated boilerplate, so stripping them would silently shrink the measured surface.
|
||||
val generated = listOf(
|
||||
"**/R.class",
|
||||
"**/R\$*.class",
|
||||
"**/BuildConfig.*",
|
||||
"**/Manifest*.*",
|
||||
"**/Hilt_*.class",
|
||||
"**/Dagger*.class",
|
||||
"**/*_Hilt*",
|
||||
"**/*_GeneratedInjector.class",
|
||||
"**/hilt_aggregated_deps/**",
|
||||
"**/dagger/**",
|
||||
"**/*_Factory*",
|
||||
"**/*_MembersInjector*",
|
||||
"**/*_Provide*",
|
||||
"**/*_Impl*",
|
||||
"**/ComposableSingletons*",
|
||||
)
|
||||
classDirectories.setFrom(jacocoClassDirectories)
|
||||
sourceDirectories.setFrom(jacocoSourceDirectories)
|
||||
executionData.setFrom(jacocoExecutionData)
|
||||
}
|
||||
|
||||
// Classes = the debug variant's compiled Kotlin (AGP 9 built-in Kotlin output). All hand-written
|
||||
// code here is Kotlin, so the javac output (purely Hilt/Dagger/BuildConfig generated) is omitted.
|
||||
val debugKotlinClasses = layout.buildDirectory.dir(
|
||||
"intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes",
|
||||
)
|
||||
classDirectories.setFrom(
|
||||
fileTree(debugKotlinClasses) { exclude(generated) },
|
||||
)
|
||||
// No-regression coverage gate (closes #251; scoping from #290/#292). Fails `check` / CI when the
|
||||
// overall LINE coverage of the scoped surface above drops below `jacocoLineCoverageFloor`. This is a
|
||||
// FLOOR, not an absolute 95% target — the maintainer chose a ratchet over a fixed goal. The floor is
|
||||
// normally set a hair (~0.5–1%) below the measured baseline so ordinary run-to-run noise doesn't
|
||||
// red-flag it, while a real regression still fails the build. Manual ratchet FOR NOW: when coverage
|
||||
// rises materially, bump this number up in the SAME PR so the floor tracks reality (there is no
|
||||
// auto-ratchet yet).
|
||||
//
|
||||
// Re-ratcheted for #386 (final step of the Robolectric Compose epic #373, once infra/PoC #375 and
|
||||
// conversion batches #376-384 had all landed and proven stable): new baseline 87.89% line
|
||||
// (7994/9095), floor 0.84 — a wider ~3.9% headroom than the usual ~0.5-1%, chosen deliberately
|
||||
// conservative for this first post-epic measurement; the maintainer can tighten it further in a
|
||||
// follow-up PR. Prior baseline: 80.21% line (4838/6032), floor 0.79 (~1.2% headroom).
|
||||
val jacocoLineCoverageFloor = "0.84"
|
||||
tasks.register<JacocoCoverageVerification>("jacocoTestCoverageVerification") {
|
||||
// Same inputs as jacocoTestReport (shared vals above) so the gate enforces exactly what the
|
||||
// report shows. Depend on the unit tests so the exec data exists before verifying.
|
||||
dependsOn("testDebugUnitTest")
|
||||
group = "verification"
|
||||
description = "Fails the build if scoped JVM unit-test LINE coverage regresses below the floor."
|
||||
|
||||
// Sources = hand-written main Kotlin.
|
||||
sourceDirectories.setFrom(files("src/main/kotlin"))
|
||||
classDirectories.setFrom(jacocoClassDirectories)
|
||||
sourceDirectories.setFrom(jacocoSourceDirectories)
|
||||
executionData.setFrom(jacocoExecutionData)
|
||||
|
||||
// Exec data written by the instrumented testDebugUnitTest task. Accept the base `jacoco` plugin's
|
||||
// default location and AGP's enableUnitTestCoverage location so the wiring is robust either way.
|
||||
executionData.setFrom(
|
||||
fileTree(layout.buildDirectory) {
|
||||
include(
|
||||
"jacoco/testDebugUnitTest.exec",
|
||||
"outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec",
|
||||
)
|
||||
},
|
||||
)
|
||||
violationRules {
|
||||
rule {
|
||||
element = "BUNDLE"
|
||||
limit {
|
||||
counter = "LINE"
|
||||
value = "COVEREDRATIO"
|
||||
minimum = jacocoLineCoverageFloor.toBigDecimal()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make the aggregate `check` lifecycle task enforce the no-regression floor locally too, so a
|
||||
// coverage regression is caught by `./gradlew check` and not only in CI.
|
||||
tasks.named("check") {
|
||||
dependsOn("jacocoTestCoverageVerification")
|
||||
}
|
||||
|
||||
// --- Robolectric android-all offline resolution (issue #373) ------------------------------------
|
||||
// Robolectric runs the real Android framework on the JVM from a large `android-all-instrumented`
|
||||
// jar. By default it resolves that jar LAZILY AT TEST TIME by downloading it from Maven Central
|
||||
// (org.robolectric.internal.dependency.MavenDependencyResolver -> MavenArtifactFetcher). That
|
||||
// runtime download is unreliable on CI runners and failed the JVM Compose PoC in CI with
|
||||
// `java.lang.AssertionError at MavenArtifactFetcher ... Caused by: java.io.IOException` ("Failed to
|
||||
// fetch maven artifact"). Fix: resolve the jar through Gradle instead — reliable, cached, and
|
||||
// persisted by the CI Gradle cache, using the same repositories as every other dependency — then
|
||||
// hand it to Robolectric in OFFLINE mode so it never touches the network at test time.
|
||||
//
|
||||
// A DEDICATED resolvable configuration (deliberately NOT testImplementation/testRuntimeOnly) keeps
|
||||
// the ~200 MB instrumented framework jar OFF the JVM unit-test classpath: it must be loaded only by
|
||||
// Robolectric's sandbox classloader, never flattened onto the app's test classpath where it would
|
||||
// collide with the stub `android.jar`. `syncRobolectricAndroidAll` stages the resolved jar under
|
||||
// its Maven filename (android-all-instrumented-<version>.jar) — exactly what Robolectric's
|
||||
// LocalDependencyResolver looks up as <artifactId>-<version>.jar — and the two system properties
|
||||
// below switch Robolectric onto that offline directory (see LegacyDependencyResolver). Every
|
||||
// Robolectric test pins @Config(sdk = 36) (app/src/test/resources/robolectric.properties), so the
|
||||
// single sdk=36 jar covers them all; a test on a different SDK must add that android-all version to
|
||||
// this configuration too. The offline properties are inert for non-Robolectric JVM tests.
|
||||
val robolectricAndroidAll: Configuration = configurations.create("robolectricAndroidAll") {
|
||||
isCanBeConsumed = false
|
||||
isCanBeResolved = true
|
||||
}
|
||||
|
||||
val robolectricDepsDir = layout.buildDirectory.dir("robolectric-android-all")
|
||||
|
||||
val syncRobolectricAndroidAll = tasks.register<Sync>("syncRobolectricAndroidAll") {
|
||||
description = "Stages Robolectric's android-all-instrumented jar for offline resolution (issue #373)."
|
||||
from(robolectricAndroidAll)
|
||||
into(robolectricDepsDir)
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
dependsOn(syncRobolectricAndroidAll)
|
||||
systemProperty("robolectric.offline", "true")
|
||||
systemProperty("robolectric.dependency.dir", robolectricDepsDir.get().asFile.absolutePath)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -332,6 +546,24 @@ dependencies {
|
||||
// The real org.json for unit tests (android.jar ships a stubbed, no-op version).
|
||||
testImplementation("org.json:json:20231013")
|
||||
|
||||
// Robolectric-backed JVM Compose UI tests (issue #373): Robolectric runs the Android framework
|
||||
// on the JVM so `createComposeRule()` can drive composables without an emulator, bringing screen
|
||||
// render code into the JaCoCo JVM-testable surface. The Compose test artifacts come from the same
|
||||
// BOM as the app (aligned versions) and reuse the ui-test-junit4 / ui-test-manifest aliases the
|
||||
// androidTest source set already declares — here in `test` (JVM), not `androidTest`. Robolectric
|
||||
// sources Android's real org.json from its sandbox, so it does not clash with the stub-replacing
|
||||
// org.json above (that is for the plain, non-Robolectric JVM tests).
|
||||
testImplementation(libs.robolectric)
|
||||
// The android-all-instrumented framework jar Robolectric loads into its sandbox — resolved via
|
||||
// Gradle and staged for offline use by syncRobolectricAndroidAll above so no flaky test-time
|
||||
// download happens in CI (issue #373). On its own dedicated configuration, NOT the test
|
||||
// classpath — see that block for why. The artifact has no transitive dependencies (verified from
|
||||
// its POM), so it resolves to exactly the one staged jar.
|
||||
"robolectricAndroidAll"(libs.robolectric.android.all.instrumented)
|
||||
testImplementation(platform(libs.androidx.compose.bom))
|
||||
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
testImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(libs.androidx.espresso.intents)
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 3,
|
||||
"identityHash": "553c7a19d228b3ba8f8a750232b4c7d9",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "accounts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL DEFAULT 0, `authError` TEXT, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "email",
|
||||
"columnName": "email",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "authType",
|
||||
"columnName": "authType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sortOrder",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "authError",
|
||||
"columnName": "authError",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.host",
|
||||
"columnName": "imap_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.port",
|
||||
"columnName": "imap_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "imap.security",
|
||||
"columnName": "imap_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.host",
|
||||
"columnName": "smtp_host",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.port",
|
||||
"columnName": "smtp_port",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "smtp.security",
|
||||
"columnName": "smtp_security",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "credentials",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "encryptedSecret",
|
||||
"columnName": "encryptedSecret",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "account_settings",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `retentionCount` INTEGER, `retentionMonths` INTEGER, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signature",
|
||||
"columnName": "signature",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signatureEnabled",
|
||||
"columnName": "signatureEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationsEnabled",
|
||||
"columnName": "notificationsEnabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "retentionCount",
|
||||
"columnName": "retentionCount",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "retentionMonths",
|
||||
"columnName": "retentionMonths",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
]
|
||||
},
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "accounts",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"accountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "signatures",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, `contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentHtml",
|
||||
"columnName": "contentHtml",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDefault",
|
||||
"columnName": "isDefault",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_signatures_accountId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "accounts",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"accountId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '553c7a19d228b3ba8f8a750232b4c7d9')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 20,
|
||||
"identityHash": "8264768635364869a347064a0864df9c",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "messages",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, `uid` INTEGER NOT NULL DEFAULT 0, `senderFold` TEXT NOT NULL DEFAULT '', `senderEmailFold` TEXT NOT NULL DEFAULT '', `subjectFold` TEXT NOT NULL DEFAULT '', `snippetFold` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sender",
|
||||
"columnName": "sender",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "senderEmail",
|
||||
"columnName": "senderEmail",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "snippet",
|
||||
"columnName": "snippet",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isHtml",
|
||||
"columnName": "isHtml",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestampMillis",
|
||||
"columnName": "timestampMillis",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isRead",
|
||||
"columnName": "isRead",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isStarred",
|
||||
"columnName": "isStarred",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'INBOX'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "inInbox",
|
||||
"columnName": "inInbox",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyFetched",
|
||||
"columnName": "bodyFetched",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "senderFold",
|
||||
"columnName": "senderFold",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "senderEmailFold",
|
||||
"columnName": "senderEmailFold",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subjectFold",
|
||||
"columnName": "subjectFold",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "snippetFold",
|
||||
"columnName": "snippetFold",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_messages_accountId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_timestampMillis",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"timestampMillis"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_accountId_folder_uid",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"folder",
|
||||
"uid"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId_folder_uid` ON `${TABLE_NAME}` (`accountId`, `folder`, `uid`)"
|
||||
},
|
||||
{
|
||||
"name": "index_messages_folder_inInbox_timestampMillis",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"folder",
|
||||
"inInbox",
|
||||
"timestampMillis"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_folder_inInbox_timestampMillis` ON `${TABLE_NAME}` (`folder`, `inInbox`, `timestampMillis`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "attachments",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `contentId` TEXT, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "messageId",
|
||||
"columnName": "messageId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "partIndex",
|
||||
"columnName": "partIndex",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "filename",
|
||||
"columnName": "filename",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mimeType",
|
||||
"columnName": "mimeType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sizeBytes",
|
||||
"columnName": "sizeBytes",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "contentId",
|
||||
"columnName": "contentId",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"messageId",
|
||||
"partIndex"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_attachments_messageId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"messageId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "messages",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"messageId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "outbox",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` TEXT, `attachments` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastError",
|
||||
"columnName": "lastError",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyHtml",
|
||||
"columnName": "bodyHtml",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "drafts",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "toAddresses",
|
||||
"columnName": "toAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ccAddresses",
|
||||
"columnName": "ccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bccAddresses",
|
||||
"columnName": "bccAddresses",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "body",
|
||||
"columnName": "body",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "attachments",
|
||||
"columnName": "attachments",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bodyHtml",
|
||||
"columnName": "bodyHtml",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "folders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `specialUse` INTEGER NOT NULL DEFAULT 0, `hierarchyDelimiter` TEXT, PRIMARY KEY(`accountId`, `fullName`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fullName",
|
||||
"columnName": "fullName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "role",
|
||||
"columnName": "role",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "selectable",
|
||||
"columnName": "selectable",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sortOrder",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "specialUse",
|
||||
"columnName": "specialUse",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "hierarchyDelimiter",
|
||||
"columnName": "hierarchyDelimiter",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"fullName"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "backfill_progress",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `folder` TEXT NOT NULL, `nextBeforeUid` INTEGER NOT NULL, `complete` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `folder`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "accountId",
|
||||
"columnName": "accountId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "folder",
|
||||
"columnName": "folder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "nextBeforeUid",
|
||||
"columnName": "nextBeforeUid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "complete",
|
||||
"columnName": "complete",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"accountId",
|
||||
"folder"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8264768635364869a347064a0864df9c')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -86,4 +86,25 @@ class AccountDaoTest {
|
||||
|
||||
assertNull(dao.getById("acct"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setAuthErrorStampsTheMessageIdempotentlyAndAReAddClearsIt() = runBlocking {
|
||||
dao.upsert(account("acct", "ada@example.org"))
|
||||
assertNull("a fresh account carries no error", dao.getById("acct")?.authError)
|
||||
|
||||
// The conditional UPDATE stamps the message and reports one row changed (issue #362)...
|
||||
assertEquals(1, dao.setAuthError("acct", MESSAGE))
|
||||
assertEquals(MESSAGE, dao.getById("acct")?.authError)
|
||||
// ...and is idempotent: re-writing the same message changes nothing (so the caller logs once).
|
||||
assertEquals(0, dao.setAuthError("acct", MESSAGE))
|
||||
|
||||
// A re-add rewrites the row from a fresh (null-authError) entity, clearing the error — the
|
||||
// clear-on-re-add path AccountRepositoryImpl relies on (insertAtEnd's in-place update).
|
||||
dao.upsert(account("acct", "ada@example.org"))
|
||||
assertNull("re-adding the account clears the persisted error", dao.getById("acct")?.authError)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MESSAGE = "Please remove and re-add this account with valid credentials"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import kotlinx.coroutines.runBlocking
|
||||
import org.json.JSONObject
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -21,6 +22,8 @@ import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.entity.CredentialEntity
|
||||
import org.libremail.reporting.AppLog
|
||||
import org.libremail.reporting.RingLogBuffer
|
||||
|
||||
/**
|
||||
* The one-time move performed by [AccountDataMigrator] (issue #111): copying accounts / credentials /
|
||||
@@ -142,6 +145,26 @@ class AccountDataMigratorTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun copyEmitsANonPiiAppLogBreadcrumbNamingOnlyTheMovedTables() = runBlocking<Unit> {
|
||||
seedVersion14Cache()
|
||||
val buffer = RingLogBuffer()
|
||||
AppLog.install(buffer)
|
||||
|
||||
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile)
|
||||
|
||||
val entry = buffer.snapshot()
|
||||
.single { it.message.startsWith("moved account tables into the account database") }
|
||||
assertEquals("the migration breadcrumb is a debug line", 'D', entry.level)
|
||||
listOf("accounts", "credentials", "account_settings", "signatures").forEach { table ->
|
||||
assertTrue("breadcrumb must name the moved table $table", entry.message.contains(table))
|
||||
}
|
||||
// The breadcrumb carries only table names — never the seeded email, secret, or passphrase.
|
||||
assertFalse(entry.message.contains("ada@example.org"))
|
||||
assertFalse(entry.message.contains("sealed-secret"))
|
||||
assertFalse(entry.message.contains(passphrase))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reRunningTheCopyIsIdempotentAndKeepsLaterEdits() = runBlocking<Unit> {
|
||||
seedVersion14Cache()
|
||||
@@ -252,7 +275,7 @@ class AccountDataMigratorTest {
|
||||
fun migratorDdlMatchesExportedAccountDatabaseSchema() {
|
||||
val schema = JSONObject(
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open("org.libremail.data.local.AccountDatabase/2.json")
|
||||
.open("org.libremail.data.local.AccountDatabase/3.json")
|
||||
.bufferedReader().use { it.readText() },
|
||||
).getJSONObject("database")
|
||||
val entities = schema.getJSONArray("entities")
|
||||
|
||||
@@ -63,6 +63,28 @@ class AccountMigrationTest {
|
||||
db.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 -> v3 (issue #362): a nullable `accounts.authError` appears, and existing accounts migrate to NULL
|
||||
* ("healthy — no error"). runMigrationsAndValidate confirms the resulting schema matches the exported v3
|
||||
* JSON, so any drift between the plain ADD COLUMN and the entity would fail here, not at a user's first
|
||||
* open.
|
||||
*/
|
||||
@Test
|
||||
fun migrate2To3_addsNullableAuthErrorDefaultingToNull() {
|
||||
helper.createDatabase(TEST_DB, 2).apply {
|
||||
insertAccount("a", "ada@example.org")
|
||||
close()
|
||||
}
|
||||
|
||||
val db = helper.runMigrationsAndValidate(TEST_DB, 3, true, ACCOUNT_MIGRATION_2_3)
|
||||
|
||||
db.query("SELECT authError FROM accounts WHERE id = 'a'").use { c ->
|
||||
assertTrue(c.moveToFirst())
|
||||
assertTrue("an existing account migrates to a null (healthy) authError", c.isNull(0))
|
||||
}
|
||||
db.close()
|
||||
}
|
||||
|
||||
private fun SupportSQLiteDatabase.insertAccount(id: String, email: String) {
|
||||
execSQL(
|
||||
"INSERT INTO accounts (id, email, displayName, authType, imap_host, imap_port, imap_security, " +
|
||||
|
||||
@@ -88,4 +88,29 @@ class AccountSettingsDaoTest {
|
||||
assertEquals(500, stored?.retentionCount)
|
||||
assertEquals(6, stored?.retentionMonths)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readModifyWriteAppliesTheTransformToTheStoredRow() = runBlocking {
|
||||
insertAccount()
|
||||
dao.upsert(AccountSettingsEntity("acct", signature = "old", notificationsEnabled = false))
|
||||
|
||||
// The read + transform + write run in one transaction (issue #313); the transform gets the stored
|
||||
// row and changes one field, so the un-touched fields are carried forward.
|
||||
dao.readModifyWrite("acct") { stored -> stored!!.copy(signature = "new") }
|
||||
|
||||
val result = dao.get("acct")
|
||||
assertEquals("new", result?.signature)
|
||||
assertEquals(false, result?.notificationsEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readModifyWriteTransformsANullRowForAnUnconfiguredAccount() = runBlocking {
|
||||
insertAccount()
|
||||
// No settings row yet: the transform receives null and builds the first row.
|
||||
dao.readModifyWrite("acct") { stored ->
|
||||
stored?.copy(signature = "x") ?: AccountSettingsEntity("acct", signature = "seeded")
|
||||
}
|
||||
|
||||
assertEquals("seeded", dao.get("acct")?.signature)
|
||||
}
|
||||
}
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* On-device cover for the issue-#359 keyed-open probe ([DatabaseEncryption.probeKeyedOpen]). The probe
|
||||
* is the fix for gap 2: it reaches the REAL `SQLiteConnection.nativeOpen` — the exact #359 crash site —
|
||||
* from inside `DatabaseProvisioner`'s fail-closed handler, so an open-time `UnsatisfiedLinkError` on an
|
||||
* incompatible device is caught and converted to `CacheEncryptionUnavailableException` before Room's
|
||||
* later deferred open can crash on it uncaught.
|
||||
*
|
||||
* This runs the real SQLCipher native path (mocked out of the JVM unit tests), asserting two things a
|
||||
* device is needed for: on a compatible device the probe opens+closes the keyed database WITHOUT
|
||||
* throwing, and — on every device — it leaves no file behind (it opens a throwaway sibling, never the
|
||||
* real cache, and cleans up its sidecars). All data is synthetic; nothing here is PII.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DatabaseEncryptionProbeInstrumentedTest {
|
||||
|
||||
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
private val cacheName = "probe_test_cache.db"
|
||||
private val cacheFile: File get() = context.getDatabasePath(cacheName)
|
||||
|
||||
// 64 hex chars == a 32-byte SQLCipher passphrase, matching DatabaseKeyStore's format.
|
||||
private val passphrase = "0123456789abcdef".repeat(4)
|
||||
|
||||
@Before
|
||||
@After
|
||||
fun clean() {
|
||||
cacheFile.parentFile
|
||||
?.listFiles { f -> f.name.startsWith(cacheName) }
|
||||
?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun probeKeyedOpen_reachesNativeOpen_thenLeavesNoFileBehind() {
|
||||
// The provisioner loads the native library immediately before probing (probeKeyedOpen's
|
||||
// precondition, kept out of the probe so the load happens exactly once per open); mirror that here.
|
||||
DatabaseEncryption.ensureNativeLibraryLoaded()
|
||||
// On a compatible device this returns normally (keyed nativeOpen succeeds); on an incompatible one
|
||||
// it throws UnsatisfiedLinkError here — the exact #359 signature the provisioner now fails closed
|
||||
// on. Either way, no probe file may survive.
|
||||
DatabaseEncryption.probeKeyedOpen(cacheFile, passphrase)
|
||||
|
||||
val dir = cacheFile.parentFile!!
|
||||
listOf("", "-wal", "-shm", "-journal").forEach { suffix ->
|
||||
assertFalse(
|
||||
"probe left a '$cacheName.openprobe$suffix' file behind",
|
||||
File(dir, "$cacheName.openprobe$suffix").exists(),
|
||||
)
|
||||
}
|
||||
// The probe uses a throwaway sibling, so it must never create the real cache file.
|
||||
assertFalse("probe must not create the real cache file", cacheFile.exists())
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
||||
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
|
||||
@@ -17,6 +16,8 @@ import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
import org.libremail.reporting.AppLog
|
||||
import org.libremail.reporting.RingLogBuffer
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -54,7 +55,7 @@ class DatabaseEncryptionTest {
|
||||
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
|
||||
assertTrue("file must not read as plaintext once encrypted", DatabaseEncryption.isEncrypted(dbFile))
|
||||
openEncrypted().apply {
|
||||
assertEquals(listOf("acct:1"), messageDao().observeSummaries().first().map { it.id })
|
||||
assertEquals("acct:1", messageDao().getById("acct:1")?.id)
|
||||
close()
|
||||
}
|
||||
|
||||
@@ -62,7 +63,7 @@ class DatabaseEncryptionTest {
|
||||
DatabaseEncryption.ensurePlaintext(dbFile, passphrase)
|
||||
assertFalse("file must be plaintext again after decrypt", DatabaseEncryption.isEncrypted(dbFile))
|
||||
openPlaintext().apply {
|
||||
assertEquals(listOf("acct:1"), messageDao().observeSummaries().first().map { it.id })
|
||||
assertEquals("acct:1", messageDao().getById("acct:1")?.id)
|
||||
close()
|
||||
}
|
||||
}
|
||||
@@ -103,7 +104,7 @@ class DatabaseEncryptionTest {
|
||||
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
|
||||
assertTrue("the file stays encrypted", DatabaseEncryption.isEncrypted(dbFile))
|
||||
openEncrypted().apply {
|
||||
assertEquals(listOf("acct:1"), messageDao().observeSummaries().first().map { it.id })
|
||||
assertEquals("acct:1", messageDao().getById("acct:1")?.id)
|
||||
close()
|
||||
}
|
||||
}
|
||||
@@ -120,7 +121,29 @@ class DatabaseEncryptionTest {
|
||||
|
||||
assertFalse(DatabaseEncryption.isEncrypted(dbFile))
|
||||
openPlaintext().apply {
|
||||
assertEquals(listOf("acct:1"), messageDao().observeSummaries().first().map { it.id })
|
||||
assertEquals("acct:1", messageDao().getById("acct:1")?.id)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun conversionSweepsAStaleRollbackJournalSidecar() = runBlocking<Unit> {
|
||||
openPlaintext().apply {
|
||||
messageDao().insertNew(listOf(message("acct:1")))
|
||||
close()
|
||||
}
|
||||
// A stray `-journal` left next to the file by an interrupted rollback-journal-mode session. The
|
||||
// conversion runs in journal_mode = DELETE, so `-journal` is the sidecar that can actually linger
|
||||
// (the pre-existing sweep only removed `-wal`/`-shm`) — issue #313.
|
||||
val staleJournal = File(dbFile.parentFile, "$dbName-journal")
|
||||
staleJournal.outputStream().use { it.write(0) }
|
||||
assertTrue("precondition: a stale journal exists", staleJournal.exists())
|
||||
|
||||
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
|
||||
|
||||
assertFalse("the conversion must sweep the stale -journal sidecar", staleJournal.exists())
|
||||
openEncrypted().apply {
|
||||
assertEquals("the data still round-trips", "acct:1", messageDao().getById("acct:1")?.id)
|
||||
close()
|
||||
}
|
||||
}
|
||||
@@ -147,7 +170,43 @@ class DatabaseEncryptionTest {
|
||||
} finally {
|
||||
encrypted.close()
|
||||
}
|
||||
assertEquals("Room's schema version must survive the plaintext -> encrypted conversion", 19, version)
|
||||
assertEquals("Room's schema version must survive the plaintext -> encrypted conversion", 20, version)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun conversionEmitsNonPiiAppLogBreadcrumbs() = runBlocking<Unit> {
|
||||
val buffer = RingLogBuffer()
|
||||
AppLog.install(buffer)
|
||||
|
||||
// The seeded row carries an email address so the PII assertions below are meaningful.
|
||||
openPlaintext().apply {
|
||||
messageDao().insertNew(listOf(message("acct:1")))
|
||||
close()
|
||||
}
|
||||
|
||||
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
|
||||
val afterEncrypt = buffer.snapshot()
|
||||
val converting = afterEncrypt.single { it.message.startsWith("converting local cache database") }
|
||||
assertEquals("the start breadcrumb is informational", 'I', converting.level)
|
||||
assertEquals("converting local cache database (targetEncrypted=true)", converting.message)
|
||||
val convertedAfterEncrypt = afterEncrypt.single { it.message == "local cache database converted" }
|
||||
assertEquals('D', convertedAfterEncrypt.level)
|
||||
|
||||
// Converting back to plaintext logs the same pair with the flag flipped.
|
||||
buffer.clear()
|
||||
DatabaseEncryption.ensurePlaintext(dbFile, passphrase)
|
||||
val afterDecrypt = buffer.snapshot()
|
||||
assertTrue(
|
||||
afterDecrypt.any { it.message == "converting local cache database (targetEncrypted=false)" },
|
||||
)
|
||||
assertTrue(afterDecrypt.any { it.message == "local cache database converted" })
|
||||
|
||||
// Neither conversion's breadcrumbs may leak the passphrase, the on-disk path, or account PII.
|
||||
(afterEncrypt + afterDecrypt).forEach { entry ->
|
||||
assertFalse("must not leak the passphrase", entry.message.contains(passphrase))
|
||||
assertFalse("must not leak the db file path", entry.message.contains(dbFile.absolutePath))
|
||||
assertFalse("must not leak the seeded email", entry.message.contains("ada@example.org"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun openPlaintext(): LibreMailDatabase =
|
||||
|
||||
+31
-4
@@ -16,7 +16,6 @@ import io.mockk.unmockkAll
|
||||
import io.mockk.unmockkObject
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
|
||||
@@ -137,11 +136,39 @@ class DatabaseProvisionerInstrumentedTest {
|
||||
|
||||
// The keyed open the provisioner reported must actually succeed on real SQLCipher (no crash).
|
||||
openEncrypted().apply {
|
||||
assertEquals(listOf("acct:1"), messageDao().observeSummaries().first().map { it.id })
|
||||
assertEquals("acct:1", messageDao().getById("acct:1")?.id)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue #359: with `encryptCache` on and NO cache yet (a fresh install enabling encryption), the
|
||||
* provisioner must load SQLCipher's native library, report [CacheOpenMode.Encrypted], and a real keyed
|
||||
* open must then create and read the encrypted cache — i.e. `libsqlcipher.so` actually loads and runs.
|
||||
*
|
||||
* On a 16 KB memory-page device/image (Android 15+, and the CI API-37 preview `google_apis_ps16k`
|
||||
* E2E image) an `.so` not aligned for 16 KB pages fails exactly here with `UnsatisfiedLinkError` at
|
||||
* `SQLiteConnection.nativeOpen`. Running this on that image makes the 16 KB native-lib load a tested
|
||||
* invariant, so a dependency bump that regressed alignment is caught in CI rather than on-device.
|
||||
*/
|
||||
@Test
|
||||
fun freshEncryptOnStartLoadsThe16KbNativeLibAndOpensKeyedWithoutCrashing() = runBlocking<Unit> {
|
||||
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = false))
|
||||
assertFalse("precondition: no cache file exists yet", dbFile.exists())
|
||||
|
||||
val mode = provisioner().prepareCache()
|
||||
|
||||
assertEquals(CacheOpenMode.Encrypted(passphrase), mode)
|
||||
// The keyed open must actually succeed on real SQLCipher — loading and using libsqlcipher.so on
|
||||
// whatever ABI / page size this device or emulator image uses.
|
||||
openEncrypted().apply {
|
||||
messageDao().insertNew(listOf(message("acct:1")))
|
||||
assertEquals("acct:1", messageDao().getById("acct:1")?.id)
|
||||
close()
|
||||
}
|
||||
assertTrue("the fresh cache was created in SQLCipher (encrypted) form", DatabaseEncryption.isEncrypted(dbFile))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encryptionTurnedOffDecryptsAnEncryptedCacheToPlaintext() = runBlocking<Unit> {
|
||||
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = false, appLock = false))
|
||||
@@ -154,7 +181,7 @@ class DatabaseProvisionerInstrumentedTest {
|
||||
assertEquals(CacheOpenMode.Plaintext, mode)
|
||||
assertFalse("the cache must be decrypted so the unkeyed open works", DatabaseEncryption.isEncrypted(dbFile))
|
||||
openPlaintext().apply {
|
||||
assertEquals(listOf("acct:1"), messageDao().observeSummaries().first().map { it.id })
|
||||
assertEquals("acct:1", messageDao().getById("acct:1")?.id)
|
||||
close()
|
||||
}
|
||||
}
|
||||
@@ -170,7 +197,7 @@ class DatabaseProvisionerInstrumentedTest {
|
||||
assertEquals(CacheOpenMode.Plaintext, mode)
|
||||
assertFalse("a plaintext-with-encryption-off start converts nothing", DatabaseEncryption.isEncrypted(dbFile))
|
||||
openPlaintext().apply {
|
||||
assertEquals(listOf("acct:1"), messageDao().observeSummaries().first().map { it.id })
|
||||
assertEquals("acct:1", messageDao().getById("acct:1")?.id)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
@@ -9,6 +10,7 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
@@ -16,6 +18,7 @@ import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.entity.AttachmentEntity
|
||||
import org.libremail.data.local.entity.FolderEntity
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
import org.libremail.data.local.entity.MessageSummary
|
||||
|
||||
/**
|
||||
* Schema-behavior tests on a fresh in-memory database at the current version. The migration DDL
|
||||
@@ -52,6 +55,12 @@ class LibreMailDatabaseTest {
|
||||
isStarred = false,
|
||||
)
|
||||
|
||||
/** Refreshes a [PagingSource] and returns the first loaded page's ids in order. */
|
||||
private suspend fun PagingSource<Int, MessageSummary>.refreshIds(loadSize: Int = 20): List<String> {
|
||||
val result = load(PagingSource.LoadParams.Refresh(key = null, loadSize = loadSize, placeholdersEnabled = false))
|
||||
return (result as PagingSource.LoadResult.Page).data.map { it.id }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun observeUnreadCountsAggregatesUnreadSyncedRowsPerAccountAndFolder() = runBlocking {
|
||||
val messageDao = db.messageDao()
|
||||
@@ -124,17 +133,17 @@ class LibreMailDatabaseTest {
|
||||
assertEquals(listOf("acct:1"), messageDao.getSyncedIds("acct", "INBOX"))
|
||||
|
||||
messageDao.deleteSearchRows()
|
||||
val remaining = messageDao.observeSummaries().first().map { it.id }
|
||||
assertEquals(listOf("acct:1"), remaining)
|
||||
assertEquals("the synced inbox row survives", "acct:1", messageDao.getById("acct:1")?.id)
|
||||
assertNull("the transient search-only row is cleared", messageDao.getById("acct:2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun observeSummariesReadsRowsWhoseBodiesExceedTheCursorWindow() = runBlocking {
|
||||
fun pagedSummariesReadRowsWhoseBodiesExceedTheCursorWindow() = runBlocking {
|
||||
val messageDao = db.messageDao()
|
||||
// Each body is larger than SQLite's shared (~2 MB) CursorWindow. The old list query did
|
||||
// SELECT * and dragged these bodies through the window, overflowing it with
|
||||
// "Couldn't read row … from CursorWindow" (issue #51). observeSummaries omits body, so the
|
||||
// rows stay tiny and read fine.
|
||||
// Each body is larger than SQLite's shared (~2 MB) CursorWindow. A `SELECT *` list query
|
||||
// dragged these bodies through the window, overflowing it with "Couldn't read row … from
|
||||
// CursorWindow" (issue #51). The paged mailbox projection omits body, so the rows stay tiny
|
||||
// and read fine — asserted against the real production query (issue #124/#214).
|
||||
val hugeBody = "x".repeat(3 * 1024 * 1024)
|
||||
messageDao.insertNew(
|
||||
listOf(
|
||||
@@ -143,7 +152,7 @@ class LibreMailDatabaseTest {
|
||||
),
|
||||
)
|
||||
|
||||
val ids = messageDao.observeSummaries().first().map { it.id }.toSet()
|
||||
val ids = messageDao.pagingUnifiedFolderSummaries("INBOX").refreshIds().toSet()
|
||||
|
||||
assertEquals(setOf("acct:1", "acct:2"), ids)
|
||||
}
|
||||
@@ -194,9 +203,8 @@ class LibreMailDatabaseTest {
|
||||
// Reconciling the inbox must not touch other folders' rows (windowed reconcile; whole-inbox
|
||||
// window since these rows have uid 0).
|
||||
messageDao.deleteSyncedInWindowNotIn("acct", "INBOX", minWindowUid = 0, keepIds = listOf("acct:INBOX:1"))
|
||||
assertEquals(
|
||||
setOf("acct:INBOX:1", "acct:Archive:1"),
|
||||
messageDao.observeSummaries().first().map { it.id }.toSet(),
|
||||
)
|
||||
assertEquals("the kept inbox row survives", "acct:INBOX:1", messageDao.getById("acct:INBOX:1")?.id)
|
||||
assertNull("the reconciled-away inbox row is deleted", messageDao.getById("acct:INBOX:2"))
|
||||
assertEquals("the other folder is untouched", "acct:Archive:1", messageDao.getById("acct:Archive:1")?.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import androidx.paging.PagingSource
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -320,6 +319,62 @@ class MessageDaoTest {
|
||||
assertEquals("cached-2", two.body)
|
||||
}
|
||||
|
||||
/**
|
||||
* The batched [MessageDao.updateHeaderContents] must write byte-for-byte the same row as the per-row
|
||||
* [MessageDao.updateHeaderContent] it replaces on the backfill path (issue #322): the same refreshed
|
||||
* fields and casefold columns, and the same untouched flags/body/membership. Two rows seeded
|
||||
* identically and refreshed by the two paths with the same values must end up identical.
|
||||
*/
|
||||
@Test
|
||||
fun updateHeaderContentsWritesTheSameResultAsThePerRowUpdate() = runBlocking {
|
||||
dao.insertNew(
|
||||
listOf(
|
||||
message("perRow", isStarred = true, body = "cached"),
|
||||
message("batch", isStarred = true, body = "cached"),
|
||||
),
|
||||
)
|
||||
|
||||
// Old path: the single-row update. New path: the batch, carrying identical field values.
|
||||
dao.updateHeaderContent(
|
||||
id = "perRow",
|
||||
sender = "Refreshed",
|
||||
senderEmail = "refreshed@example.org",
|
||||
subject = "Fresh",
|
||||
timestampMillis = 9_000L,
|
||||
uid = 7L,
|
||||
)
|
||||
dao.updateHeaderContents(
|
||||
listOf(
|
||||
message(
|
||||
"batch",
|
||||
sender = "Refreshed",
|
||||
senderEmail = "refreshed@example.org",
|
||||
subject = "Fresh",
|
||||
timestampMillis = 9_000L,
|
||||
uid = 7L,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// Every stored column — refreshed and preserved alike — matches between the two paths.
|
||||
val perRow = requireNotNull(dao.getById("perRow"))
|
||||
val batch = requireNotNull(dao.getById("batch"))
|
||||
assertEquals(perRow.copy(id = "id"), batch.copy(id = "id"))
|
||||
}
|
||||
|
||||
/** An empty batch is a no-op — issue #322's empty-batch boundary at the DAO transaction level. */
|
||||
@Test
|
||||
fun updateHeaderContentsWithAnEmptyBatchWritesNothing() = runBlocking {
|
||||
dao.insertNew(listOf(message("acct:1", subject = "Original", isRead = true, uid = 5)))
|
||||
|
||||
dao.updateHeaderContents(emptyList())
|
||||
|
||||
val row = requireNotNull(dao.getById("acct:1"))
|
||||
assertEquals("Original", row.subject)
|
||||
assertEquals(5L, row.uid)
|
||||
assertTrue(row.isRead)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markSyncedPromotesSearchOnlyRowsIntoTheFolder() = runBlocking {
|
||||
dao.insertNew(
|
||||
@@ -366,7 +421,9 @@ class MessageDaoTest {
|
||||
|
||||
dao.deleteByIds(listOf("a", "c"))
|
||||
|
||||
assertEquals(listOf("b"), dao.observeSummaries().first().map { it.id })
|
||||
assertNull("a is deleted", dao.getById("a"))
|
||||
assertNull("c is deleted", dao.getById("c"))
|
||||
assertEquals("b survives", "b", dao.getById("b")?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -381,7 +438,9 @@ class MessageDaoTest {
|
||||
|
||||
dao.deleteByAccount("acct")
|
||||
|
||||
assertEquals(listOf("acct2:1"), dao.observeSummaries().first().map { it.id })
|
||||
assertNull("the account's INBOX row is deleted", dao.getById("acct:1"))
|
||||
assertNull("the account's other-folder row is deleted", dao.getById("acct:Archive:1"))
|
||||
assertEquals("the other account survives", "acct2:1", dao.getById("acct2:1")?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -64,6 +64,56 @@ class MigrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v19 -> v20 (issue #187): the unified-inbox covering index appears over exactly
|
||||
* `(folder, inInbox, timestampMillis)`, cached rows survive, and the paged "All inboxes" query now
|
||||
* plans as a bounded `SEARCH` on that index with no temp B-tree sort (instead of a whole-table
|
||||
* `SCAN`). Asserting the query plan on the real Android SQLite proves the index is genuinely
|
||||
* covering the filter+order, not merely present.
|
||||
*/
|
||||
@Test
|
||||
fun migrate19To20_addsUnifiedInboxCoveringIndexUsedByTheSummaryScan() {
|
||||
helper.createDatabase(TEST_DB, 19).apply {
|
||||
// A representative spread: two synced INBOX rows, plus a transient search hit (inInbox = 0)
|
||||
// — all must survive the pure additive index migration. Fold columns default to ''.
|
||||
execSQL(
|
||||
"INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " +
|
||||
"timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched, uid) VALUES " +
|
||||
"('a:INBOX:2', 'a', 'Ada', 'ada@example.org', 'Hi', '', '', 0, 2000, 0, 0, 'INBOX', 1, 1, 2), " +
|
||||
"('b:INBOX:1', 'b', 'Bob', 'bob@example.org', 'Yo', '', '', 0, 1000, 0, 0, 'INBOX', 1, 1, 1), " +
|
||||
"('a:INBOX:9', 'a', 'Cy', 'cy@example.org', 'Q', '', '', 0, 3000, 0, 0, 'INBOX', 0, 0, 9)",
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
val db = helper.runMigrationsAndValidate(TEST_DB, 20, true, MIGRATION_19_20)
|
||||
|
||||
// The index exists over exactly (folder, inInbox, timestampMillis), in that order.
|
||||
assertEquals(
|
||||
"19->20 must create the (folder, inInbox, timestampMillis) unified-inbox covering index",
|
||||
listOf("folder", "inInbox", "timestampMillis"),
|
||||
db.indexColumns("index_messages_folder_inInbox_timestampMillis"),
|
||||
)
|
||||
// The cached rows are untouched by the additive migration.
|
||||
assertEquals("19->20 must not touch the mail cache", 3, db.count("messages"))
|
||||
// The production pagingUnifiedFolderSummaries query now SEARCHes the new index and drops the
|
||||
// temp B-tree sort (before this index it SCANned index_messages_timestampMillis whole-table).
|
||||
val plan = db.queryPlan(
|
||||
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, isRead, " +
|
||||
"isStarred, folder, inInbox, bodyFetched FROM messages " +
|
||||
"WHERE folder = 'INBOX' AND inInbox = 1 ORDER BY timestampMillis DESC",
|
||||
)
|
||||
assertTrue(
|
||||
"the unified-inbox summary query must SEARCH the covering index, not SCAN; plan was $plan",
|
||||
plan.any { it.contains("SEARCH") && it.contains("index_messages_folder_inInbox_timestampMillis") },
|
||||
)
|
||||
assertTrue(
|
||||
"the covering index must supply the ordering (no temp B-tree sort); plan was $plan",
|
||||
plan.none { it.contains("TEMP B-TREE") },
|
||||
)
|
||||
db.close()
|
||||
}
|
||||
|
||||
/** v11 -> v12 (PR #54): `folders.specialUse` appears defaulting to 0 and existing data survives. */
|
||||
@Test
|
||||
fun migrate11To12_defaultsExistingFoldersToNotSpecialUse() {
|
||||
@@ -576,6 +626,22 @@ class MigrationTest {
|
||||
c.getInt(0)
|
||||
}
|
||||
|
||||
/** Column names of [index], in index (seqno) order — empty if the index does not exist. */
|
||||
private fun SupportSQLiteDatabase.indexColumns(index: String): List<String> =
|
||||
query("PRAGMA index_info(`$index`)").use { c ->
|
||||
buildList {
|
||||
// PRAGMA index_info rows are (seqno, cid, name); the cursor yields them in seqno order.
|
||||
while (c.moveToNext()) add(c.getString(2))
|
||||
}
|
||||
}
|
||||
|
||||
/** The human-readable `detail` step of each `EXPLAIN QUERY PLAN [sql]` row (the last column). */
|
||||
private fun SupportSQLiteDatabase.queryPlan(sql: String): List<String> = query("EXPLAIN QUERY PLAN $sql").use { c ->
|
||||
buildList {
|
||||
while (c.moveToNext()) add(c.getString(c.columnCount - 1))
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TEST_DB = "migration-test.db"
|
||||
|
||||
|
||||
@@ -150,4 +150,47 @@ class SignatureDaoTest {
|
||||
// clearDefault in setDefault only touches the target account; acct2's default is untouched.
|
||||
assertEquals("b1", dao.getDefault("acct2")?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertMakingFirstDefaultMakesOnlyTheAccountsFirstSignatureDefault() = runBlocking {
|
||||
insertAccount()
|
||||
// The passed isDefault is a placeholder; the transaction decides it from the current count (#313).
|
||||
dao.insertMakingFirstDefault(signature("s-1", "First", isDefault = false))
|
||||
dao.insertMakingFirstDefault(signature("s-2", "Second", isDefault = true))
|
||||
|
||||
assertEquals(true, dao.getById("s-1")?.isDefault)
|
||||
assertEquals(false, dao.getById("s-2")?.isDefault)
|
||||
assertEquals("s-1", dao.getDefault("acct")?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletePromotingDefaultPromotesTheFirstRemainingWhenTheDefaultIsRemoved() = runBlocking {
|
||||
insertAccount()
|
||||
dao.upsert(signature("s-default", "Zeta", isDefault = true))
|
||||
dao.upsert(signature("s-other", "alpha")) // name-first among the remaining rows
|
||||
|
||||
val promoted = dao.deletePromotingDefault("s-default")
|
||||
|
||||
assertEquals("s-other", promoted)
|
||||
assertNull("the deleted default is gone", dao.getById("s-default"))
|
||||
assertEquals("the first remaining becomes default", "s-other", dao.getDefault("acct")?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletePromotingDefaultPromotesNothingForANonDefaultOrTheLastRow() = runBlocking {
|
||||
insertAccount()
|
||||
dao.upsert(signature("s-default", "Default", isDefault = true))
|
||||
dao.upsert(signature("s-plain", "Plain"))
|
||||
|
||||
// Deleting a non-default leaves the account's default untouched — nothing to promote.
|
||||
assertNull(dao.deletePromotingDefault("s-plain"))
|
||||
assertEquals("s-default", dao.getDefault("acct")?.id)
|
||||
|
||||
// Deleting the last (default) signature has no remaining row to promote.
|
||||
assertNull(dao.deletePromotingDefault("s-default"))
|
||||
assertNull(dao.getDefault("acct"))
|
||||
|
||||
// A missing id is a no-op.
|
||||
assertNull(dao.deletePromotingDefault("absent"))
|
||||
}
|
||||
}
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.attachment.AttachmentUriGrants
|
||||
import org.libremail.data.local.AccountDatabase
|
||||
import org.libremail.data.local.dao.BackfillProgressDao
|
||||
import org.libremail.data.local.dao.DraftDao
|
||||
import org.libremail.data.local.dao.FolderDao
|
||||
import org.libremail.data.local.dao.MessageDao
|
||||
import org.libremail.data.security.CredentialStore
|
||||
import org.libremail.data.security.KeystoreCrypto
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.sync.SyncScheduler
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.AuthType
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
import org.libremail.domain.model.ServerConfig
|
||||
import org.libremail.mail.AuthThrottleGate
|
||||
import org.libremail.mail.FetchedFolder
|
||||
import org.libremail.mail.ImapClient
|
||||
import org.libremail.notifications.MailNotifier
|
||||
|
||||
/**
|
||||
* On-device proof of the #403 fix against real SQLite and the real Keystore-backed [CredentialStore]:
|
||||
* when [AccountRepositoryImpl] adds an account, its credential must be resolvable the instant the new
|
||||
* account row becomes observable — the exact moment the push watchers (LibreMailApplication's collector
|
||||
* and [org.libremail.push.IdleService.reconcileWatchers], both keyed on the *accounts* table) react.
|
||||
*
|
||||
* The JVM `AccountRepositoryImplTest` pins the call ORDER with `coVerifyOrder`; this drives the same
|
||||
* production code against a real in-memory [AccountDatabase] (real `accountDao` + real `credentialDao`
|
||||
* via a real [CredentialStore]/[KeystoreCrypto]) so the transaction-commit ordering — not just the call
|
||||
* ordering — is exercised. A background collector reads the credential the moment the account first
|
||||
* appears, reproducing the reactive watcher; with the secret committed before the row it always
|
||||
* resolves. Non-DB collaborators (IMAP, scheduler, notifier, settings) are mocked the same way
|
||||
* `WorkerCacheLockDeferralInstrumentedTest` fakes its non-framework collaborators — never a framework
|
||||
* `Context`, which is the real application context.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AccountAddCredentialOrderingInstrumentedTest {
|
||||
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
private lateinit var db: AccountDatabase
|
||||
private lateinit var credentialStore: CredentialStore
|
||||
private lateinit var imapClient: ImapClient
|
||||
private lateinit var repository: AccountRepositoryImpl
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
|
||||
credentialStore = CredentialStore(KeystoreCrypto(), db.credentialDao())
|
||||
imapClient = mockk()
|
||||
coEvery { imapClient.listFolders(any()) } returns listOf(
|
||||
FetchedFolder("INBOX", "INBOX", emptyList(), selectable = true),
|
||||
)
|
||||
repository = AccountRepositoryImpl(
|
||||
context = context,
|
||||
accountDao = db.accountDao(),
|
||||
messageDao = mockk<MessageDao>(relaxed = true),
|
||||
folderDao = mockk<FolderDao>(relaxed = true),
|
||||
backfillProgressDao = mockk<BackfillProgressDao>(relaxed = true),
|
||||
draftDao = mockk<DraftDao>(relaxed = true),
|
||||
credentialStore = credentialStore,
|
||||
imapClient = imapClient,
|
||||
authGate = mockk<AuthThrottleGate>(relaxed = true),
|
||||
syncScheduler = mockk<SyncScheduler>(relaxed = true),
|
||||
accountSettingsRepository = mockk<AccountSettingsRepository>(relaxed = true),
|
||||
mailNotifier = mockk<MailNotifier>(relaxed = true),
|
||||
attachmentUriGrants = mockk<AttachmentUriGrants>(relaxed = true),
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.close()
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newAccountRowIsObservableOnlyAfterItsCredentialIsResolvable() = runBlocking {
|
||||
val account = imapAccount()
|
||||
|
||||
// A reactive watcher: read the stored secret the moment the new account row first appears in the
|
||||
// observed accounts list — exactly what IdleService.reconcileWatchers does before opening IDLE.
|
||||
val credentialAtFirstSight = CompletableDeferred<String?>()
|
||||
val observer = launch(Dispatchers.IO) {
|
||||
repository.observeAccounts().collect { accounts ->
|
||||
if (accounts.any { it.id == account.id } && !credentialAtFirstSight.isCompleted) {
|
||||
credentialAtFirstSight.complete(credentialStore.loadSecret(account.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repository.addImapAccount(account, PASSWORD).getOrThrow()
|
||||
|
||||
assertEquals(
|
||||
"the credential must be resolvable the instant the account row becomes observable (#403)",
|
||||
PASSWORD,
|
||||
withTimeout(TIMEOUT_MS) { credentialAtFirstSight.await() },
|
||||
)
|
||||
observer.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addImapAccountLeavesTheCredentialResolvableForTheStoredAccount() = runBlocking {
|
||||
val account = imapAccount()
|
||||
|
||||
repository.addImapAccount(account, PASSWORD).getOrThrow()
|
||||
|
||||
// Durability backstop: the account is persisted AND its secret round-trips through the real
|
||||
// Keystore-sealed store, so any later push (re)start resolves it rather than hitting a hard miss.
|
||||
assertEquals(PASSWORD, credentialStore.loadSecret(account.id))
|
||||
assertEquals(account.id, db.accountDao().getById(account.id)?.id)
|
||||
}
|
||||
|
||||
private fun imapAccount(id: String = "imap:ada@example.org") = Account(
|
||||
id = id,
|
||||
email = "ada@example.org",
|
||||
displayName = "Ada",
|
||||
authType = AuthType.PASSWORD_IMAP,
|
||||
imap = ServerConfig("imap.example.org", 993, MailSecurity.SSL_TLS),
|
||||
smtp = ServerConfig("smtp.example.org", 587, MailSecurity.STARTTLS),
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val PASSWORD = "app-password"
|
||||
const val TIMEOUT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.sync
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* On-device proof of issue #356's backfill pacing, on the REAL Android coroutine runtime (not
|
||||
* coroutines-test virtual time) across the CI API matrix. A real [BackfillPacer] — the primitive that
|
||||
* bounds how hard one [BackfillWorker] run drives [MailBackfiller] — must:
|
||||
*
|
||||
* - keep making forward progress across successive paced runs, so a large mailbox still fills fully even
|
||||
* though the per-run cap ends each run early (the DoD ask: history keeps filling across runs);
|
||||
* - skip its inter-slice cooldown while an interactive fetch is active, so it never stacks a fixed delay on
|
||||
* top of #355's per-page park (no pathological double-delay);
|
||||
* - end a run promptly when cancelled mid-cooldown, so a WorkManager stop / teardown is never blocked.
|
||||
*
|
||||
* Deliberately mock-free (no `mockk`, no framework `Context`): the pacer's only collaborator is a real
|
||||
* [InteractiveImapGate] and a "slice" is a plain lambda whose result the test scripts, so this exercises
|
||||
* the genuine pacing on real threads and is maximally portable across API 29-37 (and dodges the
|
||||
* mockk-on-framework-types landmines). The JVM [BackfillPacerTest] covers the exact cooldown *timing* and
|
||||
* cap arithmetic under virtual time; this proves the same contract survives the real dispatcher.
|
||||
*
|
||||
* Tests that must not pay the real 30 s cooldown hold an interactive fetch active for their duration (which
|
||||
* legitimately suppresses the cooldown), so they finish in milliseconds; the one test that deliberately
|
||||
* lets a real cooldown start cancels it long before it elapses.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class BackfillPacerInstrumentedTest {
|
||||
|
||||
@Test
|
||||
fun backfillFillsAllHistoryAcrossSuccessivePacedRunsDespiteThePerRunCap() = runBlocking<Unit> {
|
||||
val gate = InteractiveImapGate()
|
||||
val entered = CompletableDeferred<Unit>()
|
||||
val release = CompletableDeferred<Unit>()
|
||||
// Hold an interactive fetch for the whole test so the pacer legitimately skips its real cooldown —
|
||||
// keeping the run loop fast while still exercising the cap + cross-run continuation on real threads.
|
||||
val interactive = launch(Dispatchers.Default) {
|
||||
gate.withInteractive {
|
||||
entered.complete(Unit)
|
||||
release.await()
|
||||
}
|
||||
}
|
||||
entered.await()
|
||||
|
||||
val pacer = BackfillPacer(gate)
|
||||
val remaining = AtomicInteger(TOTAL_PAGES)
|
||||
val slicesRun = AtomicInteger(0)
|
||||
// Each slice fills one page; true == more pages remain (exactly MailBackfiller.runBackfill's contract).
|
||||
val slice: suspend () -> Boolean = {
|
||||
slicesRun.incrementAndGet()
|
||||
remaining.decrementAndGet() > 0
|
||||
}
|
||||
|
||||
var runs = 0
|
||||
var moreWork = true
|
||||
while (moreWork && runs < MAX_RUNS_GUARD) {
|
||||
moreWork = withTimeout(HAND_OFF_TIMEOUT_MS) { pacer.runPaced(shouldContinue = { true }, slice = slice) }
|
||||
runs++
|
||||
}
|
||||
|
||||
assertFalse("history must finish across successive paced runs", moreWork)
|
||||
assertEquals("every page is filled exactly once — no pacing-induced gap", TOTAL_PAGES, slicesRun.get())
|
||||
assertTrue("the per-run cap must force more than one run for a $TOTAL_PAGES-page mailbox", runs > 1)
|
||||
|
||||
release.complete(Unit)
|
||||
interactive.join()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveActivitySkipsTheInterSliceCooldownSoAPacedRunIsNotDoubleDelayed() = runBlocking<Unit> {
|
||||
val gate = InteractiveImapGate()
|
||||
val entered = CompletableDeferred<Unit>()
|
||||
val release = CompletableDeferred<Unit>()
|
||||
val interactive = launch(Dispatchers.Default) {
|
||||
gate.withInteractive {
|
||||
entered.complete(Unit)
|
||||
release.await()
|
||||
}
|
||||
}
|
||||
entered.await()
|
||||
|
||||
val pacer = BackfillPacer(gate)
|
||||
val results = ArrayDeque(listOf(true, true, false)) // two inter-slice gaps
|
||||
val slice: suspend () -> Boolean = { results.removeFirst() }
|
||||
|
||||
// If the cooldown were NOT skipped while interactive, two real 30 s waits would blow this bound;
|
||||
// skipping them makes the run return in milliseconds.
|
||||
val moreWork = withTimeout(HAND_OFF_TIMEOUT_MS) {
|
||||
pacer.runPaced(shouldContinue = { true }, slice = slice)
|
||||
}
|
||||
|
||||
assertFalse("the run still chains its slices to completion", moreWork)
|
||||
assertTrue("all scripted slices ran", results.isEmpty())
|
||||
|
||||
release.complete(Unit)
|
||||
interactive.join()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aCancelledRunEndsPromptlyWhileParkedInTheCooldownInsteadOfBlockingTeardown() = runBlocking<Unit> {
|
||||
// Idle gate: the cooldown is NOT skipped here, so the run genuinely parks in the ~30 s delay.
|
||||
val pacer = BackfillPacer(InteractiveImapGate())
|
||||
val slicesRun = AtomicInteger(0)
|
||||
val slice: suspend () -> Boolean = {
|
||||
slicesRun.incrementAndGet()
|
||||
true // always more work
|
||||
}
|
||||
|
||||
val job = launch(Dispatchers.Default) { pacer.runPaced(shouldContinue = { true }, slice = slice) }
|
||||
|
||||
delay(PARK_PROBE_MS) // let the first slice run and the run settle into the cooldown delay
|
||||
assertEquals("one slice ran, then the run parked in the cooldown", 1, slicesRun.get())
|
||||
|
||||
job.cancel()
|
||||
// Must return far sooner than the 30 s cooldown — proof the cooldown is a cancellable delay.
|
||||
withTimeout(HAND_OFF_TIMEOUT_MS) { job.join() }
|
||||
|
||||
assertEquals("cancelling during the cooldown must not start another slice", 1, slicesRun.get())
|
||||
assertTrue("the run ended by cancellation, not by running flat-out", job.isCancelled)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** A mailbox of enough pages that the per-run cap (4) must span several runs to fill it. */
|
||||
const val TOTAL_PAGES = 10
|
||||
|
||||
/** Stops the run loop if a pacing regression somehow never reports done. */
|
||||
const val MAX_RUNS_GUARD = 20
|
||||
|
||||
/** Slack given to a run to settle into its cooldown before we probe / cancel. */
|
||||
const val PARK_PROBE_MS = 300L
|
||||
|
||||
/** Generous bound; only a real pacing/cancellation regression (a stuck cooldown) approaches it. */
|
||||
const val HAND_OFF_TIMEOUT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.sync
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.MailProvider
|
||||
|
||||
/**
|
||||
* On-device proof of issue #361's Gmail bandwidth pacing across the CI API matrix. Production feeds
|
||||
* [GmailBandwidthTracker] from `MailRepositoryImpl.prefetchMessage`, which can race across
|
||||
* concurrently-syncing accounts under the real dispatcher, so this proves the tracker's
|
||||
* concurrent-map-backed accounting holds up under genuine concurrent updates on real threads rather
|
||||
* than coroutines-test virtual time (the JVM [GmailBandwidthTrackerTest] covers the day-rollover and
|
||||
* threshold-crossing logic in detail). Also proves [GmailSyncLimits.appliesTo] resolves the real
|
||||
* [MailProvider] presets identically to production. Deliberately mock-free — no `mockk`, no framework
|
||||
* `Context` — the tracker's only collaborator is the real wall clock (mirrors
|
||||
* `BackfillPacerInstrumentedTest`'s mock-free idiom).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class GmailBandwidthTrackerInstrumentedTest {
|
||||
|
||||
@Test
|
||||
fun concurrentDownloadsForOneAccountAllLandWithoutLosingAnUpdate() = runBlocking {
|
||||
val tracker = GmailBandwidthTracker()
|
||||
val perTask = 1_000L
|
||||
|
||||
val jobs = (1..CONCURRENT_TASKS).map {
|
||||
async(Dispatchers.Default) { tracker.recordDownload("acct", perTask) }
|
||||
}
|
||||
jobs.awaitAll()
|
||||
|
||||
assertEquals(CONCURRENT_TASKS * perTask, tracker.bytesDownloadedToday("acct"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accountsStayIsolatedUnderConcurrentRecording() = runBlocking {
|
||||
val tracker = GmailBandwidthTracker()
|
||||
|
||||
val heavy = async(Dispatchers.Default) {
|
||||
tracker.recordDownload("heavy", GmailSyncLimits.DAILY_DOWNLOAD_BUDGET_BYTES)
|
||||
}
|
||||
val light = async(Dispatchers.Default) { tracker.recordDownload("light", 1L) }
|
||||
heavy.await()
|
||||
light.await()
|
||||
|
||||
assertTrue(tracker.isOverDailyBudget("heavy"))
|
||||
assertFalse(tracker.isOverDailyBudget("light"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appliesToResolvesTheRealGmailPresetOnDevice() {
|
||||
val gmail = MailProvider.GMAIL.createAccount("user@gmail.com")
|
||||
val outlook = Account.outlook("user@outlook.com")
|
||||
|
||||
assertTrue(GmailSyncLimits.appliesTo(gmail))
|
||||
assertFalse(GmailSyncLimits.appliesTo(outlook))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CONCURRENT_TASKS = 50
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.sync
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.domain.model.MailProvider
|
||||
|
||||
/**
|
||||
* On-device proof of issue #363's iCloud connection cap, on the REAL Android coroutine runtime (not
|
||||
* coroutines-test virtual time) across the CI API matrix. A real, production-wired [IcloudConnectionLimiter]
|
||||
* (its `@Inject` constructor, so this also pins the production cap — mirrored here as [PRODUCTION_CAP]) —
|
||||
* the per-account permit gate [MailBackfiller] consults around every connection it opens for an iCloud
|
||||
* account — must:
|
||||
*
|
||||
* - let up to the cap run concurrently with no waiting;
|
||||
* - make a caller past the cap wait for a live in-flight one to release, then resume it;
|
||||
* - never gate a non-iCloud account, even while an iCloud account's cap is fully held.
|
||||
*
|
||||
* Deliberately mock-free (no `mockk`, no framework `Context`) and uses only the public production
|
||||
* constructor (no internal test-only constructor, which — unlike the JVM `test` source set —
|
||||
* `androidTest` cannot see, mirroring [BackfillPacerInstrumentedTest]'s own hardcoded-cap idiom): the
|
||||
* limiter is the whole synchronisation primitive #363 adds, so exercising it directly is both the
|
||||
* faithful behavioural test and the most portable across API 29-37. The JVM `IcloudConnectionLimiterTest`
|
||||
* / `MailBackfillerTest` cover the same contract (with a smaller configured cap for speed) plus the full
|
||||
* backfiller wiring under coroutines-test.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class IcloudConnectionLimiterInstrumentedTest {
|
||||
|
||||
private val icloudAccount = MailProvider.ICLOUD.createAccount("me@icloud.com")
|
||||
private val gmailAccount = MailProvider.GMAIL.createAccount("me@gmail.com")
|
||||
|
||||
@Test
|
||||
fun aCallerPastTheCapWaitsForALivePermitThenResumesOnceItReleases() = runBlocking<Unit> {
|
||||
val limiter = IcloudConnectionLimiter()
|
||||
|
||||
// Exhaust every production permit for this account.
|
||||
val entered = List(PRODUCTION_CAP) { CompletableDeferred<Unit>() }
|
||||
val release = CompletableDeferred<Unit>()
|
||||
val holders = entered.map { deferred ->
|
||||
launch(Dispatchers.Default) {
|
||||
limiter.withPermit(icloudAccount) {
|
||||
deferred.complete(Unit)
|
||||
release.await()
|
||||
}
|
||||
}
|
||||
}
|
||||
entered.forEach { it.await() }
|
||||
|
||||
val extraEntered = CompletableDeferred<Unit>()
|
||||
val extra = launch(Dispatchers.Default) {
|
||||
limiter.withPermit(icloudAccount) { extraEntered.complete(Unit) }
|
||||
}
|
||||
delay(PARK_PROBE_MS)
|
||||
assertFalse("a caller past the cap must wait while every permit is held", extraEntered.isCompleted)
|
||||
|
||||
release.complete(Unit)
|
||||
withTimeout(HAND_OFF_TIMEOUT_MS) { extraEntered.await() }
|
||||
holders.forEach { it.join() }
|
||||
extra.join()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNonIcloudAccountIsNeverGatedEvenWhileTheIcloudCapIsFullyHeld() = runBlocking<Unit> {
|
||||
val limiter = IcloudConnectionLimiter()
|
||||
val entered = List(PRODUCTION_CAP) { CompletableDeferred<Unit>() }
|
||||
val release = CompletableDeferred<Unit>()
|
||||
val holders = entered.map { deferred ->
|
||||
launch(Dispatchers.Default) {
|
||||
limiter.withPermit(icloudAccount) {
|
||||
deferred.complete(Unit)
|
||||
release.await()
|
||||
}
|
||||
}
|
||||
}
|
||||
entered.forEach { it.await() }
|
||||
|
||||
// A regression that gated every provider on one shared cap would hang this withTimeout.
|
||||
val ran = withTimeout(HAND_OFF_TIMEOUT_MS) { limiter.withPermit(gmailAccount) { true } }
|
||||
|
||||
assertTrue("a non-iCloud account must never wait behind the iCloud-only cap", ran)
|
||||
release.complete(Unit)
|
||||
holders.forEach { it.join() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thePermitReleasesEvenWhenTheGuardedBlockThrowsSoNoCallerIsStrandedWaiting() = runBlocking<Unit> {
|
||||
val limiter = IcloudConnectionLimiter()
|
||||
|
||||
val failed = runCatching { limiter.withPermit(icloudAccount) { throw IllegalStateException("boom") } }
|
||||
assertTrue("the failing block propagates to the caller", failed.isFailure)
|
||||
|
||||
// A regression would hang here forever instead of acquiring the "stuck" permit.
|
||||
val ran = withTimeout(HAND_OFF_TIMEOUT_MS) { limiter.withPermit(icloudAccount) { true } }
|
||||
assertTrue("a failed block must not strand its permit held", ran)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Mirrors [IcloudConnectionLimiter.MAX_CONCURRENT_CONNECTIONS] (`internal`, so not a symbolic
|
||||
* reference — see the class doc). A production regression to that constant would only make this
|
||||
* test over- or under-exhaust the cap, not silently pass, since every permit is awaited by name.
|
||||
*/
|
||||
const val PRODUCTION_CAP = 5
|
||||
|
||||
/** Slack given to a parked waiter to (wrongly) resume before we assert it is still parked. */
|
||||
const val PARK_PROBE_MS = 300L
|
||||
|
||||
/** Generous bound for the permit hand-off; only a real park/resume regression approaches it. */
|
||||
const val HAND_OFF_TIMEOUT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.sync
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* On-device proof of issue #355's interactive-vs-backfill coordination, on the REAL Android coroutine
|
||||
* runtime (not coroutines-test virtual time) across the CI API matrix. A real [InteractiveImapGate] — the
|
||||
* process-wide priority signal the reader's on-demand IMAP fetch and the background full-history backfill
|
||||
* share — must:
|
||||
*
|
||||
* - park a backfill-style waiter ([InteractiveImapGate.awaitInteractiveIdle], used verbatim by
|
||||
* [MailBackfiller.yieldToInteractive]) for as long as an interactive fetch is in flight, and resume it
|
||||
* the instant the fetch clears;
|
||||
* - stay held across several overlapping interactive fetches until the last releases;
|
||||
* - release even when a wrapped fetch throws — so backfill can never deadlock behind a failed message open.
|
||||
*
|
||||
* Deliberately mock-free (no `mockk`, no framework `Context`): the gate is the whole synchronisation
|
||||
* primitive #355 adds, so exercising it directly is both the faithful behavioural test and the most
|
||||
* portable across API 29–37. The JVM `InteractiveImapGateTest` / `MailBackfillerTest` cover the same
|
||||
* contract plus the full backfiller wiring under coroutines-test.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class InteractiveImapGateInstrumentedTest {
|
||||
|
||||
@Test
|
||||
fun interactiveFetchParksABackfillWaiterUntilItReleasesThenResumesIt() = runBlocking<Unit> {
|
||||
val gate = InteractiveImapGate()
|
||||
val entered = CompletableDeferred<Unit>()
|
||||
val release = CompletableDeferred<Unit>()
|
||||
|
||||
// An interactive fetch (e.g. openMessage) holds the gate until we release it.
|
||||
val interactive = launch(Dispatchers.Default) {
|
||||
gate.withInteractive {
|
||||
entered.complete(Unit)
|
||||
release.await()
|
||||
}
|
||||
}
|
||||
entered.await()
|
||||
assertTrue("the gate is held while the interactive fetch runs", gate.isInteractiveActive())
|
||||
|
||||
// A backfill-style waiter yields to the gate exactly as MailBackfiller does before each page.
|
||||
val pagesFetched = AtomicInteger(0)
|
||||
val backfill = launch(Dispatchers.Default) {
|
||||
gate.awaitInteractiveIdle()
|
||||
pagesFetched.incrementAndGet() // stands in for the next server page
|
||||
}
|
||||
delay(PARK_PROBE_MS)
|
||||
assertEquals("backfill must park while the interactive fetch holds the gate", 0, pagesFetched.get())
|
||||
|
||||
release.complete(Unit)
|
||||
withTimeout(HAND_OFF_TIMEOUT_MS) { backfill.join() }
|
||||
assertEquals("backfill pages once the interactive fetch clears", 1, pagesFetched.get())
|
||||
assertFalse("the gate clears after the fetch releases", gate.isInteractiveActive())
|
||||
interactive.join()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anErroredInteractiveFetchStillReleasesTheGateSoBackfillNeverDeadlocks() = runBlocking<Unit> {
|
||||
val gate = InteractiveImapGate()
|
||||
|
||||
val failed = runCatching { gate.withInteractive { throw IllegalStateException("open failed") } }
|
||||
|
||||
assertTrue("the failing fetch propagates to the caller", failed.isFailure)
|
||||
assertFalse("a failed fetch must not strand the gate held", gate.isInteractiveActive())
|
||||
// A backfill waiter must return at once now; a regression would hang until the timeout fires.
|
||||
withTimeout(HAND_OFF_TIMEOUT_MS) { gate.awaitInteractiveIdle() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theGateStaysHeldUntilTheLastOfSeveralConcurrentFetchesReleases() = runBlocking<Unit> {
|
||||
val gate = InteractiveImapGate()
|
||||
val entered1 = CompletableDeferred<Unit>()
|
||||
val entered2 = CompletableDeferred<Unit>()
|
||||
val release1 = CompletableDeferred<Unit>()
|
||||
val release2 = CompletableDeferred<Unit>()
|
||||
|
||||
val holder1 = launch(Dispatchers.Default) {
|
||||
gate.withInteractive {
|
||||
entered1.complete(Unit)
|
||||
release1.await()
|
||||
}
|
||||
}
|
||||
val holder2 = launch(Dispatchers.Default) {
|
||||
gate.withInteractive {
|
||||
entered2.complete(Unit)
|
||||
release2.await()
|
||||
}
|
||||
}
|
||||
entered1.await()
|
||||
entered2.await()
|
||||
assertEquals("both concurrent fetches count", 2, gate.activeInteractiveCount.value)
|
||||
|
||||
val resumed = CompletableDeferred<Unit>()
|
||||
launch(Dispatchers.Default) {
|
||||
gate.awaitInteractiveIdle()
|
||||
resumed.complete(Unit)
|
||||
}
|
||||
|
||||
release1.complete(Unit)
|
||||
delay(PARK_PROBE_MS)
|
||||
assertFalse("still parked while one interactive fetch remains in flight", resumed.isCompleted)
|
||||
|
||||
release2.complete(Unit)
|
||||
withTimeout(HAND_OFF_TIMEOUT_MS) { resumed.await() }
|
||||
assertEquals("the gate clears only after the last fetch releases", 0, gate.activeInteractiveCount.value)
|
||||
holder1.join()
|
||||
holder2.join()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Slack given to a parked waiter to (wrongly) resume before we assert it is still parked. */
|
||||
const val PARK_PROBE_MS = 300L
|
||||
|
||||
/** Generous bound for the gate hand-off; only a real park/resume regression approaches it. */
|
||||
const val HAND_OFF_TIMEOUT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -143,7 +143,14 @@ class WorkerCacheLockDeferralInstrumentedTest {
|
||||
appContext: Context,
|
||||
workerClassName: String,
|
||||
workerParameters: WorkerParameters,
|
||||
) = BackfillWorker(appContext, workerParameters, lazyBackfiller, cacheGuard)
|
||||
) = BackfillWorker(
|
||||
appContext,
|
||||
workerParameters,
|
||||
lazyBackfiller,
|
||||
cacheGuard,
|
||||
// A real pacer (#356); never reached here — the run defers on the locked cache first.
|
||||
BackfillPacer(InteractiveImapGate()),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.debug
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.work.ListenableWorker.Result
|
||||
import androidx.work.WorkerFactory
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import dagger.Lazy
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.unmockkAll
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.security.EncryptedCacheGuard
|
||||
import org.libremail.data.security.PassphraseSession
|
||||
import org.libremail.data.settings.AppSettings
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.data.sync.BackfillPacer
|
||||
import org.libremail.data.sync.BackfillWorker
|
||||
import org.libremail.data.sync.DebugFetchGate
|
||||
import org.libremail.data.sync.FetchScope
|
||||
import org.libremail.data.sync.InteractiveImapGate
|
||||
import org.libremail.data.sync.MailBackfiller
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* On-device proof of the debug-only fetch gate (issue #393): the adb-reachable [FetchGateReceiver]
|
||||
* updates [DebugFetchGate] and returns the resulting state as ordered-broadcast result data (exactly
|
||||
* what `adb shell am broadcast ... FETCH_GATE` prints back to the harness), and a gated proactive path
|
||||
* ([BackfillWorker]) genuinely defers while an un-gated path keeps running. The broadcast is sent
|
||||
* ordered — the same delivery mode `am broadcast` uses — so [BroadcastReceiver.getResultData] on the
|
||||
* final receiver reads back what the gate set, with no logcat race.
|
||||
*
|
||||
* The worker-deferral cases reuse `WorkerCacheLockDeferralInstrumentedTest`'s approach: build a
|
||||
* [BackfillWorker] with a real, never-unlocked-or-off [EncryptedCacheGuard] and a `Lazy` [MailBackfiller]
|
||||
* whose resolution is observable, so "the gate deferred before touching the DB" is proven by the `Lazy`
|
||||
* never being resolved.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FetchGateReceiverInstrumentedTest {
|
||||
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
// A fresh, real, never-unlocked session per test — so the real guard reports UNLOCKED only because
|
||||
// app-lock is off (see [unlockedGuard]), never because of leftover auth state.
|
||||
private val session = PassphraseSession()
|
||||
|
||||
@Before
|
||||
@After
|
||||
fun resetGate() {
|
||||
DebugFetchGate.reset()
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pauseUpdatesTheGateAndReturnsTheReadBack() {
|
||||
val data = sendGateBroadcast(FetchGateReceiver.ACTION_PAUSE, "backfill,prefetch")
|
||||
|
||||
assertEquals("paused=[backfill,prefetch]", data)
|
||||
assertTrue(DebugFetchGate.isPaused(FetchScope.BACKFILL))
|
||||
assertTrue(DebugFetchGate.isPaused(FetchScope.PREFETCH))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resumeAllClearsTheGateAndReturnsAnEmptyReadBack() {
|
||||
sendGateBroadcast(FetchGateReceiver.ACTION_PAUSE, "all")
|
||||
|
||||
val data = sendGateBroadcast(FetchGateReceiver.ACTION_RESUME, "all")
|
||||
|
||||
assertEquals("paused=[]", data)
|
||||
assertFalse(DebugFetchGate.isPaused(FetchScope.BACKFILL))
|
||||
assertFalse(DebugFetchGate.isPaused(FetchScope.PREFETCH))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun queryReadsBackTheStateWithoutMutatingIt() {
|
||||
sendGateBroadcast(FetchGateReceiver.ACTION_PAUSE, "backfill")
|
||||
|
||||
val data = sendGateBroadcast(FetchGateReceiver.ACTION_QUERY, scope = null)
|
||||
|
||||
assertEquals("paused=[backfill]", data)
|
||||
assertTrue(DebugFetchGate.isPaused(FetchScope.BACKFILL))
|
||||
assertFalse(DebugFetchGate.isPaused(FetchScope.PREFETCH))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBackfillPausedGateDefersTheBackfillWorkerWithoutResolvingTheBackfiller() = runBlocking<Unit> {
|
||||
sendGateBroadcast(FetchGateReceiver.ACTION_PAUSE, "backfill")
|
||||
val lazyBackfiller = mockk<Lazy<MailBackfiller>>()
|
||||
val worker = TestListenableWorkerBuilder<BackfillWorker>(context)
|
||||
.setWorkerFactory(backfillWorkerFactory(lazyBackfiller, unlockedGuard()))
|
||||
.build()
|
||||
|
||||
val result = withTimeout(TIMEOUT_MS) { worker.doWork() }
|
||||
|
||||
assertEquals(Result.retry(), result)
|
||||
// The gate deferred BEFORE any DB-backed work — the Lazy was never resolved.
|
||||
verify(exactly = 0) { lazyBackfiller.get() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPrefetchOnlyPauseLeavesTheBackfillWorkerRunning() = runBlocking<Unit> {
|
||||
// The worker gate honours BACKFILL only; pausing PREFETCH must NOT defer history paging — the
|
||||
// on-device analogue of "on-demand open and header sync stay live while prefetch is paused".
|
||||
sendGateBroadcast(FetchGateReceiver.ACTION_PAUSE, "prefetch")
|
||||
val backfiller = mockk<MailBackfiller> { coEvery { runBackfill(any()) } returns false }
|
||||
val lazyBackfiller = mockk<Lazy<MailBackfiller>> { every { get() } returns backfiller }
|
||||
val worker = TestListenableWorkerBuilder<BackfillWorker>(context)
|
||||
.setWorkerFactory(backfillWorkerFactory(lazyBackfiller, unlockedGuard()))
|
||||
.build()
|
||||
|
||||
val result = withTimeout(TIMEOUT_MS) { worker.doWork() }
|
||||
|
||||
assertEquals(Result.success(), result)
|
||||
verify { lazyBackfiller.get() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the [FetchGateReceiver.ACTION] broadcast to the receiver by explicit component (mirroring
|
||||
* `am broadcast -n`), ordered, and returns the result data the receiver set (the harness read-back).
|
||||
*/
|
||||
private fun sendGateBroadcast(action: String, scope: String?): String {
|
||||
val latch = CountDownLatch(1)
|
||||
val readBack = arrayOfNulls<String>(1)
|
||||
val intent = Intent(FetchGateReceiver.ACTION).apply {
|
||||
component = ComponentName(context, FetchGateReceiver::class.java)
|
||||
putExtra(FetchGateReceiver.EXTRA_ACTION, action)
|
||||
if (scope != null) putExtra(FetchGateReceiver.EXTRA_SCOPE, scope)
|
||||
}
|
||||
context.sendOrderedBroadcast(
|
||||
intent,
|
||||
null,
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(c: Context, i: Intent) {
|
||||
readBack[0] = resultData
|
||||
latch.countDown()
|
||||
}
|
||||
},
|
||||
null,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
assertTrue("gate broadcast timed out", latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS))
|
||||
return requireNotNull(readBack[0]) { "receiver set no result data" }
|
||||
}
|
||||
|
||||
/** A real [EncryptedCacheGuard] reporting UNLOCKED (app-lock off) — so only the gate can defer. */
|
||||
private fun unlockedGuard(): EncryptedCacheGuard {
|
||||
val settingsRepository = mockk<SettingsRepository>()
|
||||
every { settingsRepository.settings } returns flowOf(AppSettings(appLock = false, encryptCache = true))
|
||||
return EncryptedCacheGuard(settingsRepository, session)
|
||||
}
|
||||
|
||||
private fun backfillWorkerFactory(lazyBackfiller: Lazy<MailBackfiller>, cacheGuard: EncryptedCacheGuard) =
|
||||
object : WorkerFactory() {
|
||||
override fun createWorker(
|
||||
appContext: Context,
|
||||
workerClassName: String,
|
||||
workerParameters: WorkerParameters,
|
||||
) = BackfillWorker(
|
||||
appContext,
|
||||
workerParameters,
|
||||
lazyBackfiller,
|
||||
cacheGuard,
|
||||
// A real pacer (#356); the gate-deferral tests never reach it.
|
||||
BackfillPacer(InteractiveImapGate()),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.di
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.mockk.Runs
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.AccountDataMigrator
|
||||
import org.libremail.data.local.DatabaseEncryption
|
||||
import org.libremail.data.local.DatabaseFiles
|
||||
import org.libremail.data.local.DatabaseProvisioner
|
||||
import org.libremail.data.security.DatabaseKeyStore
|
||||
import org.libremail.data.settings.AppSettings
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Pins the fail-closed contract's ONE resilience exception (issue #359): the plaintext account store is
|
||||
* never encrypted and never uses SQLCipher, so a cache-encryption native-load failure — which the
|
||||
* provisioner surfaces as `CacheEncryptionUnavailableException` — must NOT brick it. If it did, the app
|
||||
* couldn't read accounts to render the encryption error gate or assemble the PII-free problem report.
|
||||
*
|
||||
* Mirrors [DatabaseModuleInstrumentedTest]'s style: MockK collaborators, a real [ContextWrapper] (never
|
||||
* `mockk<Context>()`, which trips an ART parameter-annotation mismatch on API 31/32), and a real
|
||||
* [DatabaseProvisioner] whose encryption gate is forced to fail via a spied [DatabaseEncryption].
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AccountDatabaseModuleInstrumentedTest {
|
||||
|
||||
private val appContext = ApplicationProvider.getApplicationContext<Context>()
|
||||
private val cacheDbName = "accountmodule_cache_test.db"
|
||||
private val accountsDbName = "accountmodule_accounts_test.db"
|
||||
private val cacheFile: File get() = appContext.getDatabasePath(cacheDbName)
|
||||
private val accountsFile: File get() = appContext.getDatabasePath(accountsDbName)
|
||||
|
||||
// 64 hex chars == a 32-byte SQLCipher passphrase.
|
||||
private val passphrase = "0123456789abcdef".repeat(4)
|
||||
|
||||
private val keyStore = mockk<DatabaseKeyStore>()
|
||||
private val settingsRepository = mockk<SettingsRepository>()
|
||||
private val migrator = mockk<AccountDataMigrator>()
|
||||
|
||||
// Route the provisioner's cache lookup and Room's account-store lookup to this test's private files,
|
||||
// never the app's real databases.
|
||||
private val context: Context = object : ContextWrapper(appContext) {
|
||||
override fun getDatabasePath(name: String): File = when (name) {
|
||||
DatabaseFiles.NAME -> cacheFile
|
||||
DatabaseFiles.ACCOUNTS_NAME -> accountsFile
|
||||
else -> super.getDatabasePath(name)
|
||||
}
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
clean()
|
||||
coEvery { keyStore.isClearPending() } returns false
|
||||
coEvery { keyStore.resolvePassphrase(any()) } returns passphrase
|
||||
coEvery { migrator.migrateIfNeeded() } just Runs
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkAll()
|
||||
clean()
|
||||
}
|
||||
|
||||
private fun clean() {
|
||||
listOf(cacheDbName, accountsDbName).forEach { name ->
|
||||
appContext.deleteDatabase(name)
|
||||
appContext.getDatabasePath(name).parentFile?.listFiles { f -> f.name.startsWith(name) }
|
||||
?.forEach { it.delete() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun provisioner() = DatabaseProvisioner(context, keyStore, settingsRepository, migrator, Dispatchers.IO)
|
||||
|
||||
@Test
|
||||
fun accountStoreStillOpensWhenTheCacheEncryptionLibraryFailsToLoad() = runBlocking<Unit> {
|
||||
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = false))
|
||||
mockkObject(DatabaseEncryption) // spy: real impls run except the forced failure below
|
||||
// Fault injection: the encrypted-cache gate can't load SQLCipher, so prepareCache() fails closed
|
||||
// with CacheEncryptionUnavailableException — the exact condition provideAccountDatabase tolerates.
|
||||
every { DatabaseEncryption.ensureNativeLibraryLoaded() } throws
|
||||
UnsatisfiedLinkError("dlopen failed: libsqlcipher.so is not loadable")
|
||||
|
||||
val database = AccountDatabaseModule.provideAccountDatabase(context, provisioner())
|
||||
try {
|
||||
// The plaintext account store opens and a query succeeds despite the cache-encryption failure.
|
||||
assertEquals(emptyList<Any>(), database.accountDao().getAll())
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import io.mockk.mockkObject
|
||||
import io.mockk.unmockkAll
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
@@ -138,7 +137,7 @@ class DatabaseModuleInstrumentedTest {
|
||||
// ever opened this genuinely-encrypted file with the plaintext framework helper instead of
|
||||
// SQLCipher's, this would throw (a plaintext driver can't parse SQLCipher ciphertext) rather
|
||||
// than return the seeded row.
|
||||
assertEquals(listOf("acct:1"), database.messageDao().observeSummaries().first().map { it.id })
|
||||
assertEquals("acct:1", database.messageDao().getById("acct:1")?.id)
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
@@ -157,7 +156,7 @@ class DatabaseModuleInstrumentedTest {
|
||||
val database = DatabaseModule.provideDatabase(context, provisioner())
|
||||
try {
|
||||
database.messageDao().insertNew(listOf(message("acct:1")))
|
||||
assertEquals(listOf("acct:1"), database.messageDao().observeSummaries().first().map { it.id })
|
||||
assertEquals("acct:1", database.messageDao().getById("acct:1")?.id)
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
@@ -182,7 +181,7 @@ class DatabaseModuleInstrumentedTest {
|
||||
val database = DatabaseModule.provideDatabase(context, provisioner())
|
||||
try {
|
||||
assertThrows(Throwable::class.java) {
|
||||
runBlocking { database.messageDao().observeSummaries().first() }
|
||||
runBlocking { database.messageDao().getById("acct:1") }
|
||||
}
|
||||
} finally {
|
||||
runCatching { database.close() }
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.mail
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.domain.model.MailProvider
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
|
||||
/**
|
||||
* On-device proof of issue #362's proactive auth circuit-breaker on the REAL Android runtime (not the
|
||||
* JVM stubs), across the CI API matrix. A real [AuthThrottleGate] with an always-Yahoo policy and a
|
||||
* manual clock — so it is deterministic and never real-sleeps — must, for a Yahoo/AOL account: block a
|
||||
* login after an auth failure, escalate rapid failures without reaching the ~1-hour lockout window, open
|
||||
* a long fixed circuit past the threshold, isolate accounts, and clear on success; and it must be a
|
||||
* total no-op for a non-gated host.
|
||||
*
|
||||
* Deliberately mock-free (no `mockk`, no framework `Context`): the gate's only inputs are plain lambdas
|
||||
* and value objects, so this exercises the genuine state machine on the device and dodges the
|
||||
* mockk-on-framework-types landmines. The JVM [AuthThrottleGateTest] covers the same contract under
|
||||
* coroutines-test virtual time; this proves it survives the real dispatcher and API levels.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AuthThrottleGateInstrumentedTest {
|
||||
|
||||
private var now = 0L
|
||||
private val yahoo = ProviderAuthPolicy.forHost(MailProvider.YAHOO.createAccount("x@yahoo.com").imap.host)
|
||||
|
||||
private fun gate(policyForHost: (String) -> AuthCadencePolicy = { yahoo }) =
|
||||
AuthThrottleGate(nowMillis = { now }, random = { 0.0 }, policyForHost = policyForHost)
|
||||
|
||||
private fun params(user: String = "user@example.org", host: String = "imap.mail.yahoo.com") =
|
||||
ImapConnectionParams(host, PORT, MailSecurity.SSL_TLS, user, secret = "secret", useXoauth2 = false)
|
||||
|
||||
@Test
|
||||
fun anAuthFailureBlocksTheAccountAndEscalatesWithoutReachingTheLockout() {
|
||||
val gate = gate()
|
||||
val p = params()
|
||||
|
||||
val first = gate.onAuthFailure(p)
|
||||
assertTrue("a failure blocks the account", gate.isAuthBlocked(p))
|
||||
assertTrue("the first block is positive", first > 0L)
|
||||
|
||||
val second = gate.onAuthFailure(p)
|
||||
assertTrue("a consecutive failure backs off longer", second > first)
|
||||
|
||||
// Never as long as the ~1-hour lockout the backoff exists to avoid.
|
||||
repeat(RAPID_FAILURES) { assertTrue(gate.onAuthFailure(p) < ONE_HOUR_MS) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theCircuitLatchesPastTheThresholdAndNeverSelfClears() {
|
||||
val gate = gate()
|
||||
val p = params()
|
||||
|
||||
repeat(yahoo.circuitOpenThreshold) { gate.onAuthFailure(p) }
|
||||
assertTrue("reaching the threshold latches the circuit", gate.isAuthLatched(p))
|
||||
assertTrue(gate.isAuthBlocked(p))
|
||||
|
||||
// Issue #362 fail-loud stop: the old self-clearing open-circuit window is gone — time never
|
||||
// unblocks a latch, only a fresh account re-add does.
|
||||
now += yahoo.circuitOpenMillis * LATCH_ELAPSE_FACTOR
|
||||
assertTrue("a latched circuit never self-clears with time", gate.isAuthBlocked(p))
|
||||
|
||||
// A success does not clear a latch either (defensive: no login is attempted while latched).
|
||||
gate.onAuthSuccess(p)
|
||||
assertTrue("a success must not clear a latch", gate.isAuthLatched(p))
|
||||
|
||||
// Only a re-add drops it, letting a fresh credential log in again.
|
||||
gate.onAccountReadded(p)
|
||||
assertFalse("a re-add clears the latch", gate.isAuthLatched(p))
|
||||
assertFalse(gate.isAuthBlocked(p))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accountsAreIsolatedAndSuccessClearsTheBackoff() {
|
||||
val gate = gate()
|
||||
val blocked = params(user = "blocked@example.org")
|
||||
val healthy = params(user = "healthy@example.org")
|
||||
|
||||
gate.onAuthFailure(blocked)
|
||||
assertTrue(gate.isAuthBlocked(blocked))
|
||||
assertFalse("one blocked account never stalls another", gate.isAuthBlocked(healthy))
|
||||
|
||||
gate.onAuthSuccess(blocked)
|
||||
assertFalse("a successful login clears the backoff", gate.isAuthBlocked(blocked))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theWindowClearsOnceItElapses() {
|
||||
val gate = gate()
|
||||
val p = params()
|
||||
|
||||
val block = gate.onAuthFailure(p)
|
||||
now += block
|
||||
assertFalse("the account may retry once the window elapses", gate.isAuthBlocked(p))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNonGatedHostIsNeverBlocked() {
|
||||
val gate = gate(policyForHost = ProviderAuthPolicy::forHost)
|
||||
val gmail = params(host = "imap.gmail.com")
|
||||
|
||||
assertEquals(0L, gate.onAuthFailure(gmail))
|
||||
assertFalse(gate.isAuthBlocked(gmail))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PORT = 993
|
||||
const val RAPID_FAILURES = 10
|
||||
const val ONE_HOUR_MS = 60 * 60_000L
|
||||
|
||||
/** How many old open-circuit windows to fast-forward to prove a latch never self-clears (#362). */
|
||||
const val LATCH_ELAPSE_FACTOR = 10
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.mail.graph
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.sync.AccountThrottleGate
|
||||
|
||||
/**
|
||||
* Exercises the issue #364 Graph throttling toolkit on a real device/emulator (the CI E2E matrix is
|
||||
* authoritative for this): the `$batch` call-volume reduction, the chunked upload session, and the 429
|
||||
* composition with the shared #360 [AccountThrottleGate] all run under the Android runtime and its real
|
||||
* `android.util.Log` (no JVM stub to mock). Deliberately avoids the retry-delay path so it needs no
|
||||
* coroutines-test virtual clock (unavailable on the androidTest classpath) — the delay/backoff schedule
|
||||
* is proven under virtual time in the JVM suite (GraphThrottleTest).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class GraphThrottleInstrumentedTest {
|
||||
|
||||
private val accountId = "outlook:user@example.org"
|
||||
|
||||
/** A no-network [GraphHttpClient] that records requests and returns scripted responses. */
|
||||
private class FakeClient(private val responder: (GraphRequest, Int) -> GraphResponse) : GraphHttpClient() {
|
||||
val requests = mutableListOf<GraphRequest>()
|
||||
override suspend fun execute(request: GraphRequest): GraphResponse {
|
||||
val index = requests.size
|
||||
requests += request
|
||||
return responder(request, index)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun batch_collapses_many_operations_into_few_calls() = runBlocking {
|
||||
val client = FakeClient { request, _ ->
|
||||
val requested = JSONObject(String(request.body!!, Charsets.UTF_8)).getJSONArray("requests")
|
||||
val responses = JSONArray()
|
||||
for (i in 0 until requested.length()) {
|
||||
responses.put(JSONObject().put("id", requested.getJSONObject(i).getString("id")).put("status", 200))
|
||||
}
|
||||
GraphResponse(status = 200, body = JSONObject().put("responses", responses).toString())
|
||||
}
|
||||
val requests = (1..25).map { GraphSubRequest(id = it.toString(), method = "GET", url = "/me/messages/$it") }
|
||||
|
||||
val responses = GraphBatch(GraphThrottle(AccountThrottleGate())).execute(accountId, client, requests)
|
||||
|
||||
assertEquals(2, client.requests.size)
|
||||
assertEquals(25, responses.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upload_session_uploads_over_threshold_content_in_chunks() = runBlocking {
|
||||
val chunk = 320 * 1024
|
||||
val client = FakeClient { request, _ ->
|
||||
if (request.method == "POST") {
|
||||
GraphResponse(200, JSONObject().put("uploadUrl", "https://upload.example/1").toString())
|
||||
} else {
|
||||
GraphResponse(202, "")
|
||||
}
|
||||
}
|
||||
val total = 4 * 1024 * 1024 + 500
|
||||
val content = ByteArray(total) { (it % 251).toByte() }
|
||||
val session = GraphUploadSession(GraphThrottle(AccountThrottleGate()))
|
||||
assertTrue(session.requiresUploadSession(total.toLong()))
|
||||
|
||||
session.upload(accountId, client, "https://graph/createUploadSession", JSONObject(), content, chunk)
|
||||
|
||||
val puts = client.requests.drop(1)
|
||||
assertEquals((total + chunk - 1) / chunk, puts.size)
|
||||
assertTrue(puts.all { it.method == "PUT" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_429_records_and_a_success_clears_the_shared_gate() = runBlocking {
|
||||
val gate = AccountThrottleGate()
|
||||
val throttle = GraphThrottle(gate)
|
||||
|
||||
// maxRetries = 0 → no backoff delay; the 429 is recorded and returned.
|
||||
val client429 = FakeClient { _, _ -> GraphResponse(429, "") }
|
||||
val throttled = throttle.execute(accountId, client429, sendMail(), maxRetries = 0)
|
||||
assertEquals(429, throttled.status)
|
||||
assertTrue("an unrecovered 429 backs the account off", gate.isThrottled(accountId))
|
||||
|
||||
val ok = throttle.execute(accountId, FakeClient { _, _ -> GraphResponse(202, "") }, sendMail())
|
||||
assertEquals(202, ok.status)
|
||||
assertFalse("a later success clears the backoff", gate.isThrottled(accountId))
|
||||
}
|
||||
|
||||
private fun sendMail() = GraphRequest("POST", "https://graph.microsoft.com/v1.0/me/sendMail")
|
||||
}
|
||||
@@ -13,9 +13,11 @@ import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Locks in the notification deep-link contract: a message id round-trips build → parse, and intents
|
||||
* for different messages are distinct under [Intent.filterEquals] — the identity PendingIntent keys
|
||||
* on — so per-message notifications never collapse onto one shared PendingIntent.
|
||||
* Locks in the notification deep-link contract: a message id round-trips build → parse, intents for
|
||||
* different messages are distinct under [Intent.filterEquals] — the identity PendingIntent keys on —
|
||||
* so per-message notifications never collapse onto one shared PendingIntent, and an `ACTION_OPEN_MESSAGE`
|
||||
* intent that does not carry this app's own sender token is ignored so a foreign caller targeting the
|
||||
* exported activity cannot drive the reader (#307).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class NotificationIntentsTest {
|
||||
@@ -25,20 +27,31 @@ class NotificationIntentsTest {
|
||||
@Test
|
||||
fun message_id_round_trips_through_the_intent() {
|
||||
val id = "imap:user@example.com:INBOX:42"
|
||||
assertEquals(id, NotificationIntents.messageId(NotificationIntents.openMessage(context, id)))
|
||||
assertEquals(id, NotificationIntents.messageId(context, NotificationIntents.openMessage(context, id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uri_hostile_ids_round_trip() {
|
||||
val id = "imap:user@example.com:[Gmail]/All Mail:7?&%#"
|
||||
assertEquals(id, NotificationIntents.messageId(NotificationIntents.openMessage(context, id)))
|
||||
assertEquals(id, NotificationIntents.messageId(context, NotificationIntents.openMessage(context, id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun other_intents_carry_no_message_id() {
|
||||
assertNull(NotificationIntents.messageId(null))
|
||||
assertNull(NotificationIntents.messageId(Intent(Intent.ACTION_MAIN)))
|
||||
assertNull(NotificationIntents.messageId(Intent(Intent.ACTION_VIEW, Uri.parse("mailto:a@b.c"))))
|
||||
assertNull(NotificationIntents.messageId(context, null))
|
||||
assertNull(NotificationIntents.messageId(context, Intent(Intent.ACTION_MAIN)))
|
||||
assertNull(NotificationIntents.messageId(context, Intent(Intent.ACTION_VIEW, Uri.parse("mailto:a@b.c"))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun open_message_intent_without_our_sender_token_is_ignored() {
|
||||
// A hostile app can target the exported activity with our action (and a message id), but it
|
||||
// cannot mint a PendingIntent attributed to us — so an ACTION_OPEN_MESSAGE intent that lacks our
|
||||
// sender token must be ignored, while the genuine one (built by openMessage) still resolves.
|
||||
val genuine = NotificationIntents.openMessage(context, "imap:a@b:INBOX:1")
|
||||
val forged = Intent().setAction(genuine.action)
|
||||
assertNull(NotificationIntents.messageId(context, forged))
|
||||
assertEquals("imap:a@b:INBOX:1", NotificationIntents.messageId(context, genuine))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.push
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.data.sync.PushMode
|
||||
|
||||
/**
|
||||
* On-device coverage of the #354 dataSync-FGS degrade path that [IdleService.onStartCommand] routes
|
||||
* through [IdleForegroundStarter]. When a foreground start is rejected — the runtime-cap
|
||||
* `ForegroundServiceStartNotAllowedException`, surfaced as its [IllegalStateException] supertype — the
|
||||
* seam must catch it, skip IDLE watching, and degrade to periodic sync plus the degraded
|
||||
* ("instant delivery paused") notification, never propagating. This drives the same decision seam the
|
||||
* service uses and builds the real degraded notification with a real application `Context` (a
|
||||
* `ContextWrapper`, never a mocked `Context`), mirroring `PushStatusNotificationInstrumentedTest`; it
|
||||
* stands up no foreground service, Hilt graph, or network, so it is deterministic — and unlike a JVM
|
||||
* unit test it exercises the real `Notification` build (the unit-test `android.jar`'s
|
||||
* `NotificationCompat` is a no-op stub).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class IdleServiceForegroundStartInstrumentedTest {
|
||||
|
||||
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
|
||||
@Test
|
||||
fun rejectedForegroundStart_degradesToPeriodicSyncWithPausedNotification_andSkipsWatching() {
|
||||
val rejection = IllegalStateException(
|
||||
"Time limit already exhausted for foreground service type dataSync",
|
||||
)
|
||||
var watchingStarted = false
|
||||
var periodicSyncScheduled = false
|
||||
var degradedNotification: Notification? = null
|
||||
|
||||
val result = IdleForegroundStarter.startForegroundOrDegrade(
|
||||
capActive = false,
|
||||
enterForeground = { throw rejection },
|
||||
onStarted = { watchingStarted = true },
|
||||
onDegraded = { cause ->
|
||||
assertSame("the runtime-cap rejection must reach the degrade path", rejection, cause)
|
||||
// Mirror IdleService.degradeToPeriodicSync on a real Context: (re)assert periodic sync and
|
||||
// build the degraded status notification the service would post.
|
||||
periodicSyncScheduled = true
|
||||
PushStatusNotification.ensureChannel(context)
|
||||
degradedNotification = PushStatusNotification.build(context, PushMode.POLLING, timedOut = true)
|
||||
},
|
||||
)
|
||||
|
||||
assertEquals(Service.START_NOT_STICKY, result)
|
||||
assertFalse("a rejected dataSync FGS start must not begin IDLE watching", watchingStarted)
|
||||
assertTrue("the degrade path must (re)assert the 15-minute periodic sync fallback", periodicSyncScheduled)
|
||||
val notification = requireNotNull(degradedNotification) { "the degrade path must build a status notification" }
|
||||
assertEquals(
|
||||
"the degraded notification must show the instant-delivery-paused text",
|
||||
context.getString(R.string.notif_push_status_text_timed_out),
|
||||
notification.extras.getCharSequence(Notification.EXTRA_TEXT).toString(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun activeCapWindow_skipsForegroundStartAttempt_andStillDegrades() {
|
||||
var enterForegroundAttempted = false
|
||||
var watchingStarted = false
|
||||
var degraded = false
|
||||
|
||||
val result = IdleForegroundStarter.startForegroundOrDegrade(
|
||||
capActive = true,
|
||||
enterForeground = { enterForegroundAttempted = true },
|
||||
onStarted = { watchingStarted = true },
|
||||
onDegraded = { degraded = true },
|
||||
)
|
||||
|
||||
assertEquals(Service.START_NOT_STICKY, result)
|
||||
assertFalse("must not attempt a dataSync FGS start while still inside the cap window", enterForegroundAttempted)
|
||||
assertFalse(watchingStarted)
|
||||
assertTrue("must fall back to periodic sync while capped", degraded)
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.reporting
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.security.KeystoreCrypto
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* On-device proof for issue #369: with at-rest encryption ON, [ReportStore] persists a report as real
|
||||
* Android Keystore ciphertext — no report content in plaintext on disk — and reads it back intact;
|
||||
* with it OFF the file stays plaintext JSON. This closes the gap between the JVM-tested ReportStore
|
||||
* branching (which fakes the cipher) and the device-only [KeystoreCrypto] the branching drives in
|
||||
* production, using the same non-auth master key that lets a crash-while-locked report still be sealed.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ReportStoreEncryptionInstrumentedTest {
|
||||
|
||||
private val context =
|
||||
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
|
||||
private val dir = File(context.cacheDir, "report-encryption-test")
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encryptedReportIsCiphertextOnDiskAndReadsBack() {
|
||||
val store = store(enabled = true)
|
||||
|
||||
store.save(report("enc"))
|
||||
|
||||
val raw = File(dir, "enc.json").readText()
|
||||
// The distinctive plaintext token must NOT be on disk — the report is Keystore-sealed at rest.
|
||||
assertFalse("report content must not be persisted in plaintext", raw.contains(SENTINEL))
|
||||
assertFalse("a sealed report is not plaintext JSON", raw.startsWith("{"))
|
||||
// A fresh store over the same directory (same master key) unseals and reads it back intact.
|
||||
assertEquals(SENTINEL, store(enabled = true).find("enc")?.logs?.single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plaintextReportWhenEncryptionOff() {
|
||||
val store = store(enabled = false)
|
||||
|
||||
store.save(report("plain"))
|
||||
|
||||
val raw = File(dir, "plain.json").readText()
|
||||
assertTrue("with encryption off the report stays plaintext JSON", raw.contains(SENTINEL))
|
||||
assertTrue(raw.startsWith("{"))
|
||||
}
|
||||
|
||||
private fun store(enabled: Boolean): ReportStore {
|
||||
val crypto = KeystoreCrypto()
|
||||
val encryption = object : ReportEncryption {
|
||||
override fun enabled(): Boolean = enabled
|
||||
override fun encrypt(plaintext: String): String = crypto.encrypt(plaintext)
|
||||
override fun decrypt(encoded: String): String = crypto.decrypt(encoded)
|
||||
}
|
||||
return ReportStore(dir, CoroutineScope(Dispatchers.Unconfined), encryption)
|
||||
}
|
||||
|
||||
private fun report(id: String) = DebugReport(
|
||||
id = id,
|
||||
createdAtMillis = 1_000L,
|
||||
kind = ReportKind.CRASH,
|
||||
appVersionName = "1.0",
|
||||
appVersionCode = 1L,
|
||||
androidRelease = "14",
|
||||
androidSdkInt = 34,
|
||||
deviceManufacturer = "Test",
|
||||
deviceModel = "Model",
|
||||
stackTrace = SENTINEL,
|
||||
settings = emptyMap(),
|
||||
logs = listOf(SENTINEL),
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val SENTINEL = "SENTINEL-PLAINTEXT-TOKEN-369"
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package org.libremail.ui
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
@@ -6,7 +6,7 @@ import android.app.Instrumentation
|
||||
import android.content.Context
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
|
||||
+76
-25
@@ -1,22 +1,22 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.accountsetup
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Instrumentation
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.test.espresso.intent.Intents
|
||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
|
||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasData
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.hamcrest.CoreMatchers.allOf
|
||||
import jakarta.mail.AuthenticationFailedException
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -31,8 +31,17 @@ import org.libremail.ui.theme.LibreMailTheme
|
||||
* [AppPasswordSetupScreen] + [AppPasswordViewModel] over a [FakeAccountRepository] for the preset
|
||||
* Gmail vendor: the provider-specific chrome renders, entering an email + app password and tapping
|
||||
* "Test & add" persists through the repository and reports the new account id, and tapping the
|
||||
* "create an app password" help link fires the browser intent. That launch is asserted with
|
||||
* Espresso-Intents (mirroring `AccountPickerScreenTest`'s Outlook test), so no real browser opens.
|
||||
* "create an app password" help link opens the provider's help page.
|
||||
*
|
||||
* The outbound help links are verified by injecting a recording [UriHandler] for [LocalUriHandler]
|
||||
* and asserting the URL the screen asked to open — deliberately NOT via Espresso-Intents. The two
|
||||
* approaches verify the same behaviour, but `Intents.intended(...)` runs an `onView(isRoot())` view
|
||||
* assertion whose `RootViewPicker` waits up to 10s for a window-focused root; on the CI emulator the
|
||||
* activity window intermittently reports `has-window-focus=false`, so that assertion flakes with
|
||||
* `RootViewWithoutFocusException` (an infra flake that fails every `intended()`-based E2E test on the
|
||||
* affected leg and forces a costly 9-min retry). Driving the link through a fake [UriHandler] keeps
|
||||
* the whole test on Compose interactions, which do not depend on window focus, so it is deterministic
|
||||
* — while still asserting the exact provider page the tap opens.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AppPasswordSetupScreenTest {
|
||||
@@ -42,6 +51,9 @@ class AppPasswordSetupScreenTest {
|
||||
|
||||
private val provider = MailProvider.GMAIL
|
||||
|
||||
// Captures the URL the screen hands to LocalUriHandler instead of launching a real browser.
|
||||
private val uriHandler = RecordingUriHandler()
|
||||
|
||||
private fun string(resId: Int, vararg args: Any) = composeTestRule.activity.getString(resId, *args)
|
||||
|
||||
private fun setContent(
|
||||
@@ -53,8 +65,10 @@ class AppPasswordSetupScreenTest {
|
||||
repository,
|
||||
)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
AppPasswordSetupScreen(onBack = {}, onAccountAdded = onAccountAdded, viewModel = viewModel)
|
||||
CompositionLocalProvider(LocalUriHandler provides uriHandler) {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
AppPasswordSetupScreen(onBack = {}, onAccountAdded = onAccountAdded, viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,26 +100,63 @@ class AppPasswordSetupScreenTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Tapping the "create an app password" link opens the provider's help page via
|
||||
* [androidx.compose.ui.platform.UriHandler], which starts an `ACTION_VIEW` intent. Stubbing that
|
||||
* intent both proves the tap launched it and stops a real browser from opening on the device.
|
||||
* Tapping the "create an app password" link opens the provider's help page via [LocalUriHandler].
|
||||
* Asserting the URL captured by [RecordingUriHandler] proves the tap requested the right page
|
||||
* without launching a real browser (and without the window-focus-dependent Espresso-Intents
|
||||
* assertion that flakes on CI — see the class comment).
|
||||
*/
|
||||
@Test
|
||||
fun tappingCreateAppPasswordPage_launchesBrowserIntentToHelpUrl() {
|
||||
setContent()
|
||||
|
||||
Intents.init()
|
||||
try {
|
||||
Intents.intending(hasAction(Intent.ACTION_VIEW))
|
||||
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null))
|
||||
composeTestRule.onNodeWithText(string(R.string.app_password_open_page, provider.displayName))
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.app_password_open_page, provider.displayName))
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
assertEquals(provider.appPasswordHelpUrl, uriHandler.lastUri)
|
||||
}
|
||||
|
||||
Intents.intended(allOf(hasAction(Intent.ACTION_VIEW), hasData(provider.appPasswordHelpUrl)))
|
||||
} finally {
|
||||
Intents.release()
|
||||
/**
|
||||
* When the connection test fails specifically because IMAP is disabled (Gmail's "not enabled for
|
||||
* IMAP use"), the screen surfaces the actionable "turn on IMAP" dialog instead of a generic error,
|
||||
* and its help link opens the provider's enable-IMAP page (#390). Driving the failure through a
|
||||
* [FakeAccountRepository] exercises the real classification + dialog wiring end to end on device;
|
||||
* the help link's target is verified through the injected [RecordingUriHandler] (see the class
|
||||
* comment for why not Espresso-Intents).
|
||||
*/
|
||||
@Test
|
||||
fun imapDisabledFailure_showsThePrompt_andHelpLinkOpensTheProviderPage() {
|
||||
setContent(
|
||||
repository = FakeAccountRepository(
|
||||
result = Result.failure(
|
||||
AuthenticationFailedException("Your account is not enabled for IMAP use"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.app_password_email)).performTextInput("me@gmail.com")
|
||||
composeTestRule.onNodeWithText(string(R.string.app_password_field)).performTextInput("app-pw-1234")
|
||||
composeTestRule.onNodeWithText(string(R.string.app_password_test_and_add)).performScrollTo().performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) {
|
||||
composeTestRule.onAllNodesWithText(string(R.string.imap_disabled_title)).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
composeTestRule.onNodeWithText(string(R.string.imap_disabled_message, provider.displayName)).assertIsDisplayed()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.imap_disabled_help)).performClick()
|
||||
|
||||
// Mirrors the previous Espresso hasHost(...) check: the Gmail enable-IMAP page is on Google's
|
||||
// support host. Verifying the exact host keeps the assertion strength without any focus wait.
|
||||
assertEquals("support.google.com", Uri.parse(uriHandler.lastUri).host)
|
||||
}
|
||||
|
||||
/** A [UriHandler] that records the last opened URL instead of starting a real `ACTION_VIEW` intent. */
|
||||
private class RecordingUriHandler : UriHandler {
|
||||
var lastUri: String? = null
|
||||
private set
|
||||
|
||||
override fun openUri(uri: String) {
|
||||
lastUri = uri
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ package org.libremail.ui.accountsetup
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
|
||||
@@ -9,12 +9,13 @@ import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.hasSetTextAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModelStore
|
||||
import androidx.room.Room
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
@@ -60,10 +61,20 @@ class ComposeScreenTest {
|
||||
|
||||
private var db: AccountDatabase? = null
|
||||
|
||||
// Holds the real ComposeViewModel built by hand in setContent() below, so closeDb() can clear()
|
||||
// it (triggering ViewModel.onCleared()) before closing the DB.
|
||||
private val viewModelStore = ViewModelStore()
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
@After
|
||||
fun closeDb() {
|
||||
// Clear the store (→ ViewModel.onCleared() → cancels viewModelScope) BEFORE closing the DB.
|
||||
// ComposeViewModel's init block launches a viewModelScope coroutine that reads the real
|
||||
// accountSettings/signature Room repositories (applySignature()); without this, that read can
|
||||
// still be in flight when the DB closes, racing a SQLITE_MISUSE ("connection is closed") —
|
||||
// the same class of teardown race fixed in SignaturesScreenTest/AccountSettingsScreenTest.
|
||||
viewModelStore.clear()
|
||||
db?.close()
|
||||
}
|
||||
|
||||
@@ -92,6 +103,7 @@ class ComposeScreenTest {
|
||||
signatureRepository = SignatureRepository(database.signatureDao()),
|
||||
settingsRepository = SettingsRepository(context),
|
||||
)
|
||||
viewModelStore.put("compose", viewModel)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
ComposeScreen(onBack = onBack, viewModel = viewModel)
|
||||
|
||||
@@ -5,7 +5,7 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsNotSelected
|
||||
import androidx.compose.ui.test.assertIsSelected
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
@@ -3,7 +3,7 @@ package org.libremail.ui.compose.format
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
@@ -3,7 +3,7 @@ package org.libremail.ui.compose.format
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ package org.libremail.ui.compose.format
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
@@ -3,7 +3,7 @@ package org.libremail.ui.drafts
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
|
||||
@@ -3,7 +3,7 @@ package org.libremail.ui.lock
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
@@ -4,7 +4,7 @@ package org.libremail.ui.mailbox
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
|
||||
@@ -6,7 +6,7 @@ import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.isDialog
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.longClick
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ package org.libremail.ui.onboarding
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
+34
-4
@@ -4,15 +4,19 @@ package org.libremail.ui.onboarding
|
||||
import android.app.Activity
|
||||
import android.app.Instrumentation
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.Settings
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
@@ -26,6 +30,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.hamcrest.CoreMatchers.allOf
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -62,6 +67,25 @@ class BatteryOptimizationStepTest {
|
||||
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable device animations (as CI's emulator-runner does) so [BatteryOptimizationScreen] renders
|
||||
* the reduced-motion static guide illustration (#174): the looping variant's infinite transition
|
||||
* would otherwise never let Compose/Espresso `waitForIdle` settle on a local emulator that boots
|
||||
* with animations on.
|
||||
*/
|
||||
@Before
|
||||
fun disableAnimations() {
|
||||
val automation = InstrumentationRegistry.getInstrumentation().uiAutomation
|
||||
listOf(
|
||||
"settings put global animator_duration_scale 0",
|
||||
"settings put global window_animation_scale 0",
|
||||
"settings put global transition_animation_scale 0",
|
||||
).forEach { command ->
|
||||
ParcelFileDescriptor.AutoCloseInputStream(automation.executeShellCommand(command))
|
||||
.use { it.readBytes() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the "add another? → battery → inbox" tail with one account already added this session,
|
||||
* starting on the add-another prompt. [handled] seeds the persisted "prompt handled" flag so the
|
||||
@@ -75,6 +99,7 @@ class BatteryOptimizationStepTest {
|
||||
BatteryOptimizationManager(context),
|
||||
ContactsPermissionManager(context),
|
||||
settingsRepository,
|
||||
SavedStateHandle(),
|
||||
)
|
||||
onboarding.onAccountAdded(FIRST_ACCOUNT_ID)
|
||||
|
||||
@@ -132,12 +157,15 @@ class BatteryOptimizationStepTest {
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick()
|
||||
|
||||
// The battery opt-in step is shown...
|
||||
// The battery opt-in step is shown, with the illustrated "Battery → Unrestricted" guide...
|
||||
waitForText(string(R.string.onboarding_battery_title))
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_title)).assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription(string(R.string.onboarding_battery_animation_description))
|
||||
.assertIsDisplayed()
|
||||
|
||||
// ...and "Not now" continues to the inbox and records the prompt as handled (so it won't nag).
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_not_now)).performClick()
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_not_now)).performScrollTo().performClick()
|
||||
waitForText(INBOX_MARKER)
|
||||
composeTestRule.onNodeWithText(INBOX_MARKER).assertIsDisplayed()
|
||||
composeTestRule.waitUntil(5_000) { runBlocking { settingsRepository.isBatteryPromptHandled() } }
|
||||
@@ -157,7 +185,9 @@ class BatteryOptimizationStepTest {
|
||||
Intents.intending(hasAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS))
|
||||
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_take_me)).performClick()
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_take_me))
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
// Deep-links to *this app's* details screen (where Battery → Unrestricted lives).
|
||||
Intents.intended(
|
||||
|
||||
@@ -3,7 +3,7 @@ package org.libremail.ui.onboarding
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
@@ -4,7 +4,7 @@ package org.libremail.ui.onboarding
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
|
||||
@@ -7,7 +7,7 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
@@ -110,6 +110,7 @@ class OnboardingFlowTest {
|
||||
BatteryOptimizationManager(appContext),
|
||||
ContactsPermissionManager(appContext),
|
||||
SettingsRepository(appContext),
|
||||
SavedStateHandle(),
|
||||
)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
@@ -134,6 +135,20 @@ class OnboardingFlowTest {
|
||||
navController.navigate(Routes.onboardingAppPassword(provider.key))
|
||||
},
|
||||
onManualSetup = {},
|
||||
onPickOutlook = { navController.navigate(Routes.ONBOARDING_OUTLOOK_IMAP) },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
composable(Routes.ONBOARDING_OUTLOOK_IMAP) {
|
||||
val viewModel = remember { AccountSetupViewModel(outlookAuthManager, accountRepo) }
|
||||
OutlookImapNoticeScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onAccountAdded = { id ->
|
||||
onboarding.onAccountAdded(id)
|
||||
navController.navigate(Routes.ONBOARDING_ADD_ANOTHER) {
|
||||
popUpTo(Routes.ONBOARDING_PICKER)
|
||||
}
|
||||
},
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
@@ -252,6 +267,23 @@ class OnboardingFlowTest {
|
||||
composeTestRule.onNodeWithText("E2E first message").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun outlookPick_showsImapNoticeBeforeAuth() {
|
||||
setOnboardingContent(FakeAccountRepository(), FakeMailRepository())
|
||||
|
||||
// Welcome → picker → tap Outlook.
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_add_account)).performClick()
|
||||
waitForText(string(R.string.account_setup_outlook))
|
||||
composeTestRule.onNodeWithText(string(R.string.account_setup_outlook)).performClick()
|
||||
|
||||
// Picking Outlook lands on the IMAP-enablement notice BEFORE any OAuth browser opens (#411):
|
||||
// the interstitial's question is shown and its bottom "Sign in" button (which would continue
|
||||
// the existing Outlook auth flow) is present.
|
||||
waitForText(string(R.string.outlook_imap_question))
|
||||
composeTestRule.onNodeWithText(string(R.string.outlook_imap_question)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.outlook_imap_sign_in)).performScrollTo().assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun yahooSetup_hasNoTwoFactorHelpLink() {
|
||||
setOnboardingContent(FakeAccountRepository(), FakeMailRepository())
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.onboarding
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Instrumentation
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.test.espresso.intent.Intents
|
||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import net.openid.appauth.AuthorizationManagementActivity
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.auth.OutlookAuthManager
|
||||
import org.libremail.ui.FakeAccountRepository
|
||||
import org.libremail.ui.accountsetup.AccountSetupViewModel
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/**
|
||||
* End-to-end UI test for the pre-auth Outlook IMAP-enablement notice (#411). Drives the real
|
||||
* [OutlookImapNoticeScreen] + [AccountSetupViewModel] over a [FakeAccountRepository]: the IMAP
|
||||
* question and both outbound links render, tapping the "How to enable IMAP" help link opens
|
||||
* Microsoft's help article, and tapping the bottom "Sign in" button starts the existing Microsoft
|
||||
* OAuth (AppAuth) flow.
|
||||
*
|
||||
* The help link is verified by injecting a recording [UriHandler] for [LocalUriHandler] and asserting
|
||||
* the opened URL — not via Espresso-Intents, whose `intended(...)` runs an `onView(isRoot())`
|
||||
* assertion that waits for a window-focused root and flakes with `RootViewWithoutFocusException` on
|
||||
* the CI emulator (see `AppPasswordSetupScreenTest` for the full write-up). The "Sign in" launch has
|
||||
* no [UriHandler] seam — AppAuth calls `startActivity` directly — so it stays on Espresso-Intents,
|
||||
* matched by AppAuth's [AuthorizationManagementActivity] component and stubbed so no real browser
|
||||
* opens; the interstitial → OAuth navigation in the full onboarding graph is covered by
|
||||
* `OnboardingFlowTest`.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class OutlookImapNoticeScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val context: Context =
|
||||
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
|
||||
|
||||
// Captures the URL the screen hands to LocalUriHandler instead of launching a real browser.
|
||||
private val uriHandler = RecordingUriHandler()
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private fun setContent(onAccountAdded: (String) -> Unit = {}) {
|
||||
val viewModel = AccountSetupViewModel(OutlookAuthManager(context), FakeAccountRepository())
|
||||
composeTestRule.setContent {
|
||||
CompositionLocalProvider(LocalUriHandler provides uriHandler) {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
OutlookImapNoticeScreen(onBack = {}, onAccountAdded = onAccountAdded, viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showsImapQuestion_bothLinks_andSignIn() {
|
||||
setContent()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.outlook_imap_question)).performScrollTo().assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.outlook_imap_help)).performScrollTo().assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.outlook_imap_settings)).performScrollTo().assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.outlook_imap_sign_in)).performScrollTo().assertIsDisplayed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Tapping the "How to enable IMAP" link opens Microsoft's help article via [LocalUriHandler].
|
||||
* Asserting the URL captured by [RecordingUriHandler] proves the tap requested the right page
|
||||
* without launching a real browser (and without the window-focus-dependent Espresso-Intents
|
||||
* assertion that flakes on CI — see the class comment).
|
||||
*/
|
||||
@Test
|
||||
fun tappingImapHelpLink_opensTheMicrosoftArticle() {
|
||||
setContent()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.outlook_imap_help))
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
assertEquals("support.microsoft.com", Uri.parse(uriHandler.lastUri).host)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tapping "Sign in" must continue the existing Outlook OAuth flow — i.e. fire the AppAuth
|
||||
* authorization intent (mirroring `AccountPickerScreenTest.tappingOutlook_...`). AppAuth wraps its
|
||||
* browser launch in an intent targeting [AuthorizationManagementActivity]; that component name is
|
||||
* the guaranteed, browser-independent signature of the launch. Stubbing a canceled result stops
|
||||
* that activity from ever resuming and opening a real browser.
|
||||
*/
|
||||
@Test
|
||||
fun tappingSignIn_launchesTheAppAuthBrowserIntent() {
|
||||
setContent()
|
||||
|
||||
Intents.init()
|
||||
try {
|
||||
Intents.intending(hasComponent(AuthorizationManagementActivity::class.java.name))
|
||||
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null))
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.outlook_imap_sign_in))
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
Intents.intended(hasComponent(AuthorizationManagementActivity::class.java.name))
|
||||
} finally {
|
||||
Intents.release()
|
||||
}
|
||||
}
|
||||
|
||||
/** A [UriHandler] that records the last opened URL instead of starting a real `ACTION_VIEW` intent. */
|
||||
private class RecordingUriHandler : UriHandler {
|
||||
var lastUri: String? = null
|
||||
private set
|
||||
|
||||
override fun openUri(uri: String) {
|
||||
lastUri = uri
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package org.libremail.ui.outbox
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
|
||||
@@ -3,7 +3,7 @@ package org.libremail.ui.reader
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
|
||||
@@ -4,7 +4,7 @@ package org.libremail.ui.reporting
|
||||
import android.content.Context
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
|
||||
@@ -7,7 +7,7 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
|
||||
@@ -5,7 +5,7 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.security
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/**
|
||||
* UI coverage for the fail-closed encrypted-cache error screen (issue #359). [CacheEncryptionErrorScreen]
|
||||
* is presentational (its report action is wired by the caller), so it is exercised in isolation: the
|
||||
* verbatim error message shows, and tapping "Report a problem" reports back.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CacheEncryptionErrorScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private fun setContent(onReportProblem: () -> Unit = {}) {
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
CacheEncryptionErrorScreen(onReportProblem = onReportProblem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showsTheVerbatimErrorMessageAndReportAction() {
|
||||
setContent()
|
||||
|
||||
// The exact maintainer-specified message must render, unchanged.
|
||||
composeTestRule.onNodeWithText(string(R.string.cache_encryption_error_message)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.cache_encryption_report_action)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingReportProblem_invokesCallback() {
|
||||
var reported = false
|
||||
setContent(onReportProblem = { reported = true })
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.cache_encryption_report_action)).performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) { reported }
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,17 @@ package org.libremail.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModelStore
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.work.WorkManager
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -54,13 +56,27 @@ class AccountSettingsScreenTest {
|
||||
|
||||
private var manageSignaturesClicked = false
|
||||
|
||||
private lateinit var db: AccountDatabase
|
||||
|
||||
// Holds the real AccountSettingsViewModel built by hand in setContent() below, so tearDown() can
|
||||
// clear() it (triggering ViewModel.onCleared()) before closing the DB.
|
||||
private val viewModelStore = ViewModelStore()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
// Clear the store (→ ViewModel.onCleared() → cancels viewModelScope) BEFORE closing the DB.
|
||||
// The ViewModel's `settings`/`signatureCount`/`defaultSignatureName`/`account` Room
|
||||
// InvalidationTracker Flows are kept alive by stateIn(WhileSubscribed(5_000)): without this,
|
||||
// a collector can still be live up to 5s after the UI detaches, so a re-query lands on the
|
||||
// just-closed in-memory DB and throws SQLITE_MISUSE ("connection is closed") — an intermittent
|
||||
// teardown race, not a real bug. (Previously worked around by never closing the DB at all.)
|
||||
viewModelStore.clear()
|
||||
db.close()
|
||||
}
|
||||
|
||||
private fun setContent(): AccountSettingsRepository {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
// Intentionally not closed in an @After: the ViewModel's `settings` Room Flow (kept alive by
|
||||
// stateIn/WhileSubscribed) keeps querying after the test body, so closing the in-memory DB out
|
||||
// from under it races and crashes ("connection pool has been closed"). The DB is reclaimed with
|
||||
// the test process.
|
||||
val db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
|
||||
db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
|
||||
val repository = AccountSettingsRepository(db.accountSettingsDao())
|
||||
runBlocking {
|
||||
db.accountDao().upsert(account.toEntity()) // FK parent for the account_settings row
|
||||
@@ -74,6 +90,7 @@ class AccountSettingsScreenTest {
|
||||
syncScheduler = SyncScheduler(Provider { WorkManager.getInstance(context) }),
|
||||
settingsRepository = SettingsRepository(context),
|
||||
)
|
||||
viewModelStore.put("account-settings", viewModel)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
AccountSettingsScreen(
|
||||
|
||||
@@ -3,7 +3,7 @@ package org.libremail.ui.settings
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
@@ -3,7 +3,7 @@ package org.libremail.ui.settings
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
|
||||
@@ -4,7 +4,7 @@ package org.libremail.ui.settings
|
||||
import android.content.Context
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
|
||||
@@ -8,12 +8,13 @@ import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.isNotSelected
|
||||
import androidx.compose.ui.test.isSelectable
|
||||
import androidx.compose.ui.test.isSelected
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModelStore
|
||||
import androidx.room.Room
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
@@ -50,6 +51,10 @@ class SignaturesScreenTest {
|
||||
private lateinit var db: AccountDatabase
|
||||
private lateinit var repository: SignatureRepository
|
||||
|
||||
// Holds the real SignaturesViewModel built by hand in setContent() below, so tearDown() can
|
||||
// clear() it (triggering ViewModel.onCleared()) before closing the DB.
|
||||
private val viewModelStore = ViewModelStore()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
|
||||
@@ -70,7 +75,15 @@ class SignaturesScreenTest {
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
fun tearDown() {
|
||||
// Clear the store (→ ViewModel.onCleared() → cancels viewModelScope) BEFORE closing the DB.
|
||||
// SignaturesViewModel.signatures is a Room InvalidationTracker Flow kept alive by
|
||||
// stateIn(WhileSubscribed(5_000)): without this, the collector can still be live up to 5s
|
||||
// after the UI detaches, so a re-query lands on the just-closed in-memory DB and throws
|
||||
// SQLITE_MISUSE ("connection is closed") — an intermittent teardown race, not a real bug.
|
||||
viewModelStore.clear()
|
||||
db.close()
|
||||
}
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
@@ -81,6 +94,7 @@ class SignaturesScreenTest {
|
||||
SavedStateHandle(mapOf(Routes.SIGNATURES_ARG_ACCOUNT to accountId)),
|
||||
repository,
|
||||
)
|
||||
viewModelStore.put("signatures", viewModel)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
SignaturesScreen(onBack = {}, onEdit = {}, onAdd = {}, viewModel = viewModel)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!--
|
||||
Debug-only test harness for issue #221 (never merged into a release APK — this manifest belongs to
|
||||
@@ -16,6 +17,21 @@
|
||||
android:authorities="${applicationId}.coldopen"
|
||||
android:exported="false"
|
||||
android:process=":coldopen" />
|
||||
|
||||
<!--
|
||||
Debug-only fetch-gate receiver (issue #393; also never merged into a release APK — this
|
||||
manifest belongs to the debug source set). Lets the on-device perf harness pause proactive
|
||||
fetch (backfill + body prefetch) via `adb shell am broadcast` so a genuine uncached
|
||||
message-open can be measured. Must be exported="true" so the adb `shell` UID can reach it by
|
||||
explicit component (`-n`); it targets the debug BuildConfig.DEBUG-guarded DebugFetchGate only,
|
||||
carries no PII, and — being debug-only — can never ship. tools:ignore suppresses the
|
||||
exported-without-permission lint note: a signature permission would (by design) also lock out
|
||||
the shell UID this hook exists to serve.
|
||||
-->
|
||||
<receiver
|
||||
android:name="org.libremail.debug.FetchGateReceiver"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedReceiver" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -10,7 +10,6 @@ import android.os.Bundle
|
||||
import androidx.room.Room
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import androidx.sqlite.db.SupportSQLiteOpenHelper
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
|
||||
import org.libremail.data.local.DatabaseEncryption
|
||||
@@ -112,8 +111,8 @@ class ColdOpenCacheProbe : ContentProvider() {
|
||||
)
|
||||
.build()
|
||||
try {
|
||||
val ids = runBlocking { database.messageDao().observeSummaries().first().map { it.id } }
|
||||
if (ids == listOf(EXPECTED_ROW_ID)) OPEN_OK else "$OPEN_ROWS$ids"
|
||||
val id = runBlocking { database.messageDao().getById(EXPECTED_ROW_ID)?.id }
|
||||
if (id == EXPECTED_ROW_ID) OPEN_OK else "$OPEN_ROWS$id"
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.debug
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import org.libremail.data.sync.DebugFetchGate
|
||||
import org.libremail.data.sync.FetchScope
|
||||
import org.libremail.reporting.AppLog
|
||||
|
||||
/**
|
||||
* Debug-only [BroadcastReceiver] (issue #393) that lets an adb-driven perf harness pause/resume the
|
||||
* proactive-fetch activities tracked by [DebugFetchGate], so a genuinely uncached message-open can be
|
||||
* measured (add an account, let headers sync, then open a message that must hit the network instead of a
|
||||
* warmed cache). Declared **only** in `app/src/debug/AndroidManifest.xml`, so it is physically absent
|
||||
* from every release APK — the same source-set guarantee `ColdOpenCacheProbe` (#221) relies on.
|
||||
*
|
||||
* Driven by (component targeted with `-n`, so it needs no `<intent-filter>`):
|
||||
* ```
|
||||
* adb shell am broadcast -a org.libremail.debug.FETCH_GATE \
|
||||
* -n org.libremail.app/org.libremail.debug.FetchGateReceiver \
|
||||
* --es action <pause|resume|query> --es scope <backfill,prefetch|all>
|
||||
* ```
|
||||
* `am broadcast` delivers this **ordered**, so the receiver returns the resulting state as result data
|
||||
* (e.g. `data=paused=[backfill,prefetch]`) which `am` prints — a synchronous, race-free read-back for
|
||||
* the harness. A `query` reports the current state without changing it. The new state is logged via the
|
||||
* PII-free [AppLog] (scope names only — never an email, host, or message content).
|
||||
*/
|
||||
class FetchGateReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val action = intent.getStringExtra(EXTRA_ACTION)?.trim()?.lowercase()
|
||||
val scopes = FetchScope.parse(intent.getStringExtra(EXTRA_SCOPE))
|
||||
when (action) {
|
||||
ACTION_PAUSE -> {
|
||||
DebugFetchGate.pause(scopes)
|
||||
AppLog.i(TAG, "fetch gate pause -> ${DebugFetchGate.pausedResult()}")
|
||||
}
|
||||
ACTION_RESUME -> {
|
||||
DebugFetchGate.resume(scopes)
|
||||
AppLog.i(TAG, "fetch gate resume -> ${DebugFetchGate.pausedResult()}")
|
||||
}
|
||||
ACTION_QUERY -> AppLog.i(TAG, "fetch gate query -> ${DebugFetchGate.pausedResult()}")
|
||||
else -> AppLog.w(TAG, "fetch gate: unknown action")
|
||||
}
|
||||
// Return the gate state as ordered-broadcast result data for a synchronous read-back. Guarded so
|
||||
// a non-ordered send (which has no result receiver) can't crash the receiver.
|
||||
if (isOrderedBroadcast) {
|
||||
resultCode = RESULT_CODE
|
||||
resultData = DebugFetchGate.pausedResult()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The broadcast action the harness sends (kept for parity with the adb command; delivery is by `-n`). */
|
||||
const val ACTION = "org.libremail.debug.FETCH_GATE"
|
||||
|
||||
/** `--es action <pause|resume|query>`. */
|
||||
const val EXTRA_ACTION = "action"
|
||||
|
||||
/** `--es scope <comma-list|all>` (see [FetchScope.parse]). */
|
||||
const val EXTRA_SCOPE = "scope"
|
||||
|
||||
const val ACTION_PAUSE = "pause"
|
||||
const val ACTION_RESUME = "resume"
|
||||
const val ACTION_QUERY = "query"
|
||||
|
||||
private const val TAG = "FetchGateReceiver"
|
||||
private const val RESULT_CODE = 0
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.libremail.data.security.KeystoreReportEncryption
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.data.sync.SyncScheduler
|
||||
import org.libremail.domain.repository.AccountRepository
|
||||
@@ -48,6 +49,8 @@ class LibreMailApplication :
|
||||
|
||||
@Inject lateinit var diagnosticsCollector: DiagnosticsCollector
|
||||
|
||||
@Inject lateinit var reportEncryption: KeystoreReportEncryption
|
||||
|
||||
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
/** Whether the IDLE push service should currently be running (push enabled AND an account exists). */
|
||||
@@ -74,6 +77,10 @@ class LibreMailApplication :
|
||||
// Warm the settings cache so a later crash report can include non-PII settings without
|
||||
// touching DataStore on the crashing thread.
|
||||
appScope.launch { runCatching { diagnosticsCollector.warmSettingsCache() } }
|
||||
// Mirror the encryptCache setting so a crash-time report save (synchronous, on the crashing
|
||||
// thread) can seal the report at rest without touching DataStore (#369). Collects for the
|
||||
// process lifetime, so a mid-session toggle takes effect on the next report write.
|
||||
appScope.launch { runCatching { reportEncryption.observeEncryptCacheSetting() } }
|
||||
syncScheduler.schedulePeriodicSync()
|
||||
// Full-history backfill (#12) and device-only retention pruning (#13) run as their own bounded,
|
||||
// resumable background jobs so they never block foreground sync / pull-to-refresh.
|
||||
|
||||
@@ -21,6 +21,7 @@ import org.libremail.ui.LibreMailApp
|
||||
import org.libremail.ui.compose.ComposePrefill
|
||||
import org.libremail.ui.compose.IntentComposeParser
|
||||
import org.libremail.ui.lock.AppLockGateHost
|
||||
import org.libremail.ui.security.CacheEncryptionGate
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -73,12 +74,18 @@ class MainActivity : FragmentActivity() {
|
||||
// Gate the whole app behind the screen-lock when app-lock is enabled. When it is off
|
||||
// the gate resolves straight to the content, so this is a no-op for most users.
|
||||
AppLockGateHost {
|
||||
LibreMailApp(
|
||||
pendingCompose = pendingCompose.value,
|
||||
onComposeHandled = { pendingCompose.value = null },
|
||||
pendingOpenMessageId = pendingOpenMessageId.value,
|
||||
onOpenMessageHandled = { pendingOpenMessageId.value = null },
|
||||
)
|
||||
// Inside the app-lock gate (so the auth-bound passphrase is already unlocked): fail
|
||||
// closed if the encrypted cache's SQLCipher library won't load (#359), showing the
|
||||
// error gate instead of ever opening the cache unencrypted. Resolves straight to the
|
||||
// content when the cache is openable, so it is a no-op for most users.
|
||||
CacheEncryptionGate {
|
||||
LibreMailApp(
|
||||
pendingCompose = pendingCompose.value,
|
||||
onComposeHandled = { pendingCompose.value = null },
|
||||
pendingOpenMessageId = pendingOpenMessageId.value,
|
||||
onOpenMessageHandled = { pendingOpenMessageId.value = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,7 +122,7 @@ class MainActivity : FragmentActivity() {
|
||||
private fun handleIntent(intent: Intent) {
|
||||
if (!IntentHandledMarker.markIfUnhandled(intent)) return
|
||||
IntentComposeParser.parse(intent)?.let { pendingCompose.value = it }
|
||||
NotificationIntents.messageId(intent)?.let { pendingOpenMessageId.value = it }
|
||||
NotificationIntents.messageId(this, intent)?.let { pendingOpenMessageId.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,12 +106,16 @@ class OutlookAuthManager @Inject constructor(@ApplicationContext private val con
|
||||
val authState = AuthState(response, exception).apply { update(tokenResponse, null) }
|
||||
val email = emailFromIdToken(tokenResponse.idToken)
|
||||
?: throw IllegalStateException("Could not read the account email from the token")
|
||||
// Mint an Exchange Online token so the caller can verify the account over IMAP.
|
||||
val outlook = refreshForScope(authState, OUTLOOK_SCOPE)
|
||||
// The code exchange above already named the Exchange Online resource ($OUTLOOK_SCOPE), so
|
||||
// this access token is an outlook.office.com token the caller can verify over IMAP directly.
|
||||
// Don't re-refresh for the same scope: that second round-trip only rotates the just-issued
|
||||
// refresh token and adds a needless onboarding failure point. The Graph token is a different
|
||||
// resource and is minted on demand later (freshGraphToken); the durable AuthState — refresh
|
||||
// token plus this token's expiry — is serialized here for those later refreshes.
|
||||
return OAuthResult(
|
||||
email = email,
|
||||
accessToken = outlook.accessToken,
|
||||
authStateJson = outlook.authStateJson,
|
||||
accessToken = tokenResponse.accessToken.orEmpty(),
|
||||
authStateJson = authState.jsonSerializeString(),
|
||||
)
|
||||
} finally {
|
||||
service.dispose()
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
@@ -15,6 +14,7 @@ import kotlinx.coroutines.withContext
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
||||
import org.libremail.data.security.DatabaseKeyStore
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.reporting.AppLog
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -98,10 +98,11 @@ class AccountDataMigrator @Inject constructor(
|
||||
private val TABLES = listOf("accounts", "credentials", "account_settings", "signatures")
|
||||
|
||||
/**
|
||||
* DDL for the account tables in [AccountDatabase] v1, copied verbatim from the exported Room
|
||||
* schema (`schemas/org.libremail.data.local.AccountDatabase/1.json`). It MUST stay byte-for-byte
|
||||
* identical to what Room generates for those entities, or Room silently accepts a subtly wrong
|
||||
* schema (its identity check only compares the hash it writes, not the pre-existing tables).
|
||||
* DDL for the account tables in [AccountDatabase] v3, copied verbatim from the exported Room
|
||||
* schema (`schemas/org.libremail.data.local.AccountDatabase/3.json` — v3 added `accounts.authError`,
|
||||
* issue #362). It MUST stay byte-for-byte identical to what Room generates for those entities, or
|
||||
* Room silently accepts a subtly wrong schema (its identity check only compares the hash it writes,
|
||||
* not the pre-existing tables).
|
||||
* `AccountDataMigratorTest.migratorDdlMatchesExportedAccountDatabaseSchema` guards it against the
|
||||
* exported schema; `internal` only so that test can read it.
|
||||
*/
|
||||
@@ -109,7 +110,7 @@ class AccountDataMigrator @Inject constructor(
|
||||
"accounts" to
|
||||
"CREATE TABLE IF NOT EXISTS `accounts` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, " +
|
||||
"`displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, " +
|
||||
"`sortOrder` INTEGER NOT NULL DEFAULT 0, `imap_host` TEXT NOT NULL, " +
|
||||
"`sortOrder` INTEGER NOT NULL DEFAULT 0, `authError` TEXT, `imap_host` TEXT NOT NULL, " +
|
||||
"`imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, " +
|
||||
"`smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"credentials" to
|
||||
@@ -181,7 +182,7 @@ class AccountDataMigrator @Inject constructor(
|
||||
"(SELECT COUNT(*) FROM `accounts` AS ranked WHERE ranked.`email` < `accounts`.`email`)",
|
||||
)
|
||||
}
|
||||
Log.d(TAG, "moved account tables into the account database: $present")
|
||||
AppLog.d(TAG, "moved account tables into the account database: $present")
|
||||
} finally {
|
||||
db.rawExecSQL("DETACH DATABASE cache;")
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ import org.libremail.data.local.entity.SignatureEntity
|
||||
AccountSettingsEntity::class,
|
||||
SignatureEntity::class,
|
||||
],
|
||||
version = 2,
|
||||
version = 3,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class AccountDatabase : RoomDatabase() {
|
||||
|
||||
@@ -31,3 +31,16 @@ val ACCOUNT_MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AccountDatabase v2 -> v3 (issue #362): add the nullable [AccountEntity.authError], a user-facing sync/auth
|
||||
* error surfaced on the account row and as a mailbox banner. The column is nullable with no default, so
|
||||
* every existing account migrates to NULL ("no error, healthy") — matching the entity's `authError: String?
|
||||
* = null` — and the proactive auth circuit later stamps the "remove and re-add" message onto an account
|
||||
* whose Yahoo/AOL login has latched. A plain ADD COLUMN suffices; nothing is backfilled.
|
||||
*/
|
||||
val ACCOUNT_MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE `accounts` ADD COLUMN `authError` TEXT")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
/**
|
||||
* Raised by [DatabaseProvisioner] when the opt-in encrypted cache cannot be opened because SQLCipher's
|
||||
* native library failed to load or link on this device (issue #359 — e.g. an `.so` the platform
|
||||
* rejects, surfacing as an `UnsatisfiedLinkError`/`LinkageError` at `SQLiteConnection.nativeOpen` or
|
||||
* from [DatabaseEncryption.ensureNativeLibraryLoaded]).
|
||||
*
|
||||
* The app must **fail closed**: it must NOT fall back to an unencrypted cache (that would silently
|
||||
* defeat the user's opt-in encryption), NOT wipe the on-disk ciphertext, and NOT touch the
|
||||
* `encryptCache` setting. Instead this distinct, expected signal is surfaced so the startup UI
|
||||
* (`CacheEncryptionGate`) can show the encryption error gate — not the mailbox, and not a crash.
|
||||
*
|
||||
* Deliberately a dedicated type (not a bare [LinkageError]) so only this precise condition is treated
|
||||
* as "encryption unavailable"; any other failure still propagates. The provisioner never memoizes it,
|
||||
* so a later launch — where the library may load, e.g. after an app update — re-attempts and recovers
|
||||
* automatically.
|
||||
*/
|
||||
class CacheEncryptionUnavailableException(cause: Throwable) :
|
||||
Exception(
|
||||
"Encrypted cache unavailable: the SQLCipher native library failed to load on this device",
|
||||
cause,
|
||||
)
|
||||
|
||||
/**
|
||||
* True when this throwable (or anything in its cause chain) signals that SQLCipher's native library is
|
||||
* unavailable on this device (issue #359): a [CacheEncryptionUnavailableException] the provisioner raised
|
||||
* when it failed closed, or — defensively — a bare [LinkageError] (an open-time `UnsatisfiedLinkError` at
|
||||
* `SQLiteConnection.nativeOpen` that reached a headless entry point before the provisioner wrapped it).
|
||||
*
|
||||
* Headless entry points that inject the Room cache directly — the WorkManager workers and `IdleService` —
|
||||
* have no UI gate (that is `CacheEncryptionGate`'s job), so they consult this to treat such a failure as a
|
||||
* soft defer/skip (a worker retries; the push service stops) instead of crashing. A later launch may load
|
||||
* the library and recover, so deferring rather than failing hard is correct. The whole cause chain is
|
||||
* walked because coroutine stack-trace recovery can re-wrap the throwable as it crosses the database-open
|
||||
* boundary (the same reason [DatabaseProvisioner]'s own tests assert on the cause chain, not the instance).
|
||||
*/
|
||||
fun Throwable.isCacheEncryptionUnavailable(): Boolean = generateSequence(this) { it.cause }
|
||||
.any { it is CacheEncryptionUnavailableException || it is LinkageError }
|
||||
@@ -1,8 +1,8 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.util.Log
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
||||
import org.libremail.reporting.AppLog
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -40,6 +40,7 @@ object DatabaseEncryption {
|
||||
* tables but not that pragma, and a reset version would make Room attempt a bogus migration.
|
||||
*/
|
||||
private fun migrate(dbFile: File, sourcePassphrase: String, targetPassphrase: String) {
|
||||
AppLog.i(TAG, "converting local cache database (targetEncrypted=${targetPassphrase.isNotEmpty()})")
|
||||
ensureNativeLibraryLoaded()
|
||||
val dir = dbFile.parentFile ?: error("database file has no parent directory")
|
||||
val tmp = File(dir, dbFile.name + ".migrate").apply { delete() }
|
||||
@@ -79,16 +80,20 @@ object DatabaseEncryption {
|
||||
target.close()
|
||||
}
|
||||
|
||||
// Swap the converted file into place; drop any stale WAL/SHM sidecars from either file first.
|
||||
// Swap the converted file into place; drop any stale sidecars from either file first. Both files
|
||||
// are in rollback-journal mode (journal_mode = DELETE), so the sidecar that can actually linger
|
||||
// after an interrupted attempt is the `-journal`; the `-wal`/`-shm` deletes are belt-and-suspenders
|
||||
// for a file left in WAL mode by an older build (mirrors AccountDataMigrator's sweep).
|
||||
listOf(dbFile.name, tmp.name).forEach { base ->
|
||||
File(dir, "$base-wal").delete()
|
||||
File(dir, "$base-shm").delete()
|
||||
File(dir, "$base-journal").delete()
|
||||
}
|
||||
if (!tmp.renameTo(dbFile)) {
|
||||
tmp.copyTo(dbFile, overwrite = true)
|
||||
tmp.delete()
|
||||
}
|
||||
Log.d(TAG, "local cache database converted")
|
||||
AppLog.d(TAG, "local cache database converted")
|
||||
}
|
||||
|
||||
private fun startsWithSqliteHeader(dbFile: File): Boolean {
|
||||
@@ -114,8 +119,45 @@ object DatabaseEncryption {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a throwaway keyed SQLCipher database next to [cacheFile] and immediately closes it, purely to
|
||||
* reach `SQLiteConnection.nativeOpen` — the exact call site of the #359 crash — from inside
|
||||
* [DatabaseProvisioner]'s fail-closed handler. [ensureNativeLibraryLoaded]
|
||||
* (`System.loadLibrary("sqlcipher")`) can succeed on a device whose bundled `.so` is otherwise
|
||||
* incompatible, yet the JNI-bound `nativeOpen` still be unresolved; that only surfaces when a keyed
|
||||
* database is actually opened, which Room does LATER via its deferred open helper
|
||||
* ([org.libremail.di.DatabaseModule]) — outside any handler. Probing the real open here lets the
|
||||
* provisioner catch that [LinkageError] and fail closed BEFORE Room reaches it.
|
||||
*
|
||||
* Deliberately opens a sibling throwaway file (never the real cache) so it can never create, mutate,
|
||||
* or leave `-wal`/`-shm` sidecars on the cache, then deletes the probe and its sidecars in a `finally`.
|
||||
* Mirrors `SqlCipherOpenSpikeTest`'s stage-B keyed-open probe (issue #359).
|
||||
*
|
||||
* PRECONDITION: the caller must have already loaded the native library (via [ensureNativeLibraryLoaded]).
|
||||
* The sole production caller — [DatabaseProvisioner]'s encrypted branch — does so on the line above its
|
||||
* probe call. Kept out of here on purpose so the provisioner loads the library exactly ONCE per open
|
||||
* (the `ensureNativeLibraryLoaded()`-exactly-once invariant its instrumented tests pin), not twice.
|
||||
*/
|
||||
fun probeKeyedOpen(cacheFile: File, passphrase: String) {
|
||||
val dir = cacheFile.parentFile ?: error("cache database file has no parent directory")
|
||||
val probe = File(dir, cacheFile.name + PROBE_SUFFIX)
|
||||
try {
|
||||
SQLiteDatabase.openOrCreateDatabase(
|
||||
probe.absolutePath,
|
||||
passphrase.toByteArray(Charsets.US_ASCII),
|
||||
null, // no CursorFactory
|
||||
null, // no DatabaseErrorHandler
|
||||
).close()
|
||||
} finally {
|
||||
listOf("", "-wal", "-shm", "-journal").forEach { File(dir, probe.name + it).delete() }
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "LibreMailDbCrypto"
|
||||
|
||||
// Suffix of the throwaway file [probeKeyedOpen] opens to reach nativeOpen without touching the cache.
|
||||
private const val PROBE_SUFFIX = ".openprobe"
|
||||
|
||||
// The 16-byte magic that opens every plaintext SQLite file: "SQLite format 3" + a NUL terminator.
|
||||
// Spelled out as bytes to keep the trailing NUL unambiguous.
|
||||
private val SQLITE_HEADER = byteArrayOf(
|
||||
|
||||
@@ -10,7 +10,10 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.libremail.data.security.DatabaseKeyStore
|
||||
import org.libremail.data.settings.AppSettings
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.reporting.AppLog
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -101,20 +104,59 @@ class DatabaseProvisioner internal constructor(
|
||||
keyStore.clearClearPending()
|
||||
}
|
||||
|
||||
// One-time move of accounts/credentials/settings/signatures into the non-auth AccountDatabase
|
||||
// (issue #111). MUST run before the cache opens: opening it applies MIGRATION_15_16, which drops
|
||||
// the moved tables. Runs AFTER the wipe above so an unrecoverable-key cache is gone first
|
||||
// (nothing left to move) and we never block waiting on a passphrase we can't get.
|
||||
accountDataMigrator.migrateIfNeeded()
|
||||
// FAIL CLOSED (issue #359, security rework of #367). SQLCipher's native library can fail to LOAD
|
||||
// (UnsatisfiedLinkError from ensureNativeLibraryLoaded) OR to LINK (the library loads, but the
|
||||
// JNI-bound SQLiteConnection.nativeOpen is unresolved and throws only when a keyed database is
|
||||
// actually opened — the exact #359 signature). EVERY startup step that touches that library must
|
||||
// therefore run inside this ONE handler, so any such LinkageError becomes a single fail-closed
|
||||
// signal rather than escaping as a raw crash. On that signal we deliberately do NOT silently
|
||||
// degrade to an unencrypted cache (that would defeat the user's opt-in encryption): we never open
|
||||
// plaintext, wipe the on-disk ciphertext, or write the encryptCache setting — we raise a distinct
|
||||
// exception the startup UI (CacheEncryptionGate) catches to show the encryption error gate. It is
|
||||
// NOT memoized (a throw skips prepareCache's `.also { prepared = it }`), so the next launch
|
||||
// re-attempts and recovers automatically if the library later loads.
|
||||
//
|
||||
// The catch stays typed LinkageError ONLY. It must NOT be broadened to Exception/Throwable: the
|
||||
// migrator below deliberately throws a NON-linkage error ("crash-loop rather than lose data") on
|
||||
// an unexpected copy failure, and that — like any other non-linkage error — must still propagate
|
||||
// uncaught so we never drop the not-yet-copied source tables.
|
||||
return try {
|
||||
// One-time move of accounts/credentials/settings/signatures into the non-auth AccountDatabase
|
||||
// (issue #111). MUST run before the cache opens: opening it applies MIGRATION_15_16, which drops
|
||||
// the moved tables. Runs AFTER the wipe above so an unrecoverable-key cache is gone first
|
||||
// (nothing left to move) and we never block waiting on a passphrase we can't get. Runs INSIDE
|
||||
// this handler (issue #359 gap 1): its copyAccountTables loads SQLCipher and does a keyed
|
||||
// openOrCreateDatabase + ATTACH … KEY (a real nativeOpen) even when encryption is OFF, so a
|
||||
// LinkageError there used to escape this handler entirely and crash-loop.
|
||||
accountDataMigrator.migrateIfNeeded()
|
||||
|
||||
// Opt-in at-rest encryption of the local cache (off by default). The conversion runs here —
|
||||
// before the database is opened — so it never races an open connection; toggling the setting
|
||||
// therefore takes effect on the next app start. The passphrase source is resolved from which
|
||||
// seal actually exists (DatabaseKeyStore.resolvePassphrase), NOT from the app-lock setting (a
|
||||
// separate DataStore that can disagree). When app-lock is ON the sealing key is auth-bound, so
|
||||
// resolvePassphrase waits on PassphraseSession until the user authenticates — which is why this
|
||||
// must never run on the main thread while the cache is locked (issue #93).
|
||||
val settings = settingsRepository.settings.first()
|
||||
// Opt-in at-rest encryption of the local cache (off by default). The conversion runs here —
|
||||
// before the database is opened — so it never races an open connection; toggling the setting
|
||||
// therefore takes effect on the next app start. The passphrase source is resolved from which
|
||||
// seal actually exists (DatabaseKeyStore.resolvePassphrase), NOT from the app-lock setting (a
|
||||
// separate DataStore that can disagree). When app-lock is ON the sealing key is auth-bound, so
|
||||
// resolvePassphrase waits on PassphraseSession until the user authenticates — which is why this
|
||||
// must never run on the main thread while the cache is locked (issue #93).
|
||||
val settings = settingsRepository.settings.first()
|
||||
resolveOpenMode(settings, dbFile)
|
||||
} catch (nativeLoadFailure: LinkageError) {
|
||||
AppLog.w(
|
||||
TAG,
|
||||
"SQLCipher native library failed to load or link; failing closed (encrypted cache unavailable)",
|
||||
nativeLoadFailure,
|
||||
)
|
||||
throw CacheEncryptionUnavailableException(nativeLoadFailure)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The encryption gate (step 3 of [runStartupSequence]): convert the on-disk cache to the form the
|
||||
* `encryptCache` setting asks for and report how Room must open it. Split out so a native-library
|
||||
* load failure on either the encrypt or the decrypt-on-disable path (both need SQLCipher's `.so`) is
|
||||
* caught in one place — see [runStartupSequence]'s handler, which fails closed by raising
|
||||
* [CacheEncryptionUnavailableException] rather than degrading to an unencrypted cache.
|
||||
*/
|
||||
private suspend fun resolveOpenMode(settings: AppSettings, dbFile: File): CacheOpenMode {
|
||||
val appLock = settings.appLock
|
||||
return when {
|
||||
settings.encryptCache -> {
|
||||
@@ -127,6 +169,13 @@ class DatabaseProvisioner internal constructor(
|
||||
// load the keyed open reaches SQLiteConnection.nativeOpen with no library loaded and
|
||||
// crashes with UnsatisfiedLinkError on every cold start once encryption is enabled.
|
||||
DatabaseEncryption.ensureNativeLibraryLoaded()
|
||||
// Probe the REAL keyed open HERE (issue #359 gap 2), inside the fail-closed handler.
|
||||
// ensureNativeLibraryLoaded() above only loads the .so; the keyed nativeOpen that can still
|
||||
// throw on an incompatible device fires LATER, in DatabaseModule's DeferredOpenHelperFactory
|
||||
// AFTER prepareCache() returns — outside any handler — so an open-time UnsatisfiedLinkError
|
||||
// there would escape uncaught. Reaching a keyed nativeOpen now converts that LinkageError to
|
||||
// CacheEncryptionUnavailableException before Room's deferred open can crash on it.
|
||||
DatabaseEncryption.probeKeyedOpen(dbFile, passphrase)
|
||||
CacheOpenMode.Encrypted(passphrase)
|
||||
}
|
||||
|
||||
@@ -140,4 +189,8 @@ class DatabaseProvisioner internal constructor(
|
||||
else -> CacheOpenMode.Plaintext
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "DatabaseProvisioner"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ import org.libremail.data.local.entity.OutboxEntity
|
||||
FolderEntity::class,
|
||||
BackfillProgressEntity::class,
|
||||
],
|
||||
version = 19,
|
||||
version = 20,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class LibreMailDatabase : RoomDatabase() {
|
||||
|
||||
@@ -39,6 +39,7 @@ internal fun AccountEntity.toDomain(): Account = Account(
|
||||
authType = runCatching { AuthType.valueOf(authType) }.getOrDefault(AuthType.PASSWORD_IMAP),
|
||||
imap = ServerConfig(imap.host, imap.port, imap.security.toMailSecurity()),
|
||||
smtp = ServerConfig(smtp.host, smtp.port, smtp.security.toMailSecurity()),
|
||||
authError = authError,
|
||||
)
|
||||
|
||||
internal fun Account.toEntity(): AccountEntity = AccountEntity(
|
||||
@@ -48,6 +49,7 @@ internal fun Account.toEntity(): AccountEntity = AccountEntity(
|
||||
authType = authType.name,
|
||||
imap = ServerConfigEmbedded(imap.host, imap.port, imap.security.name),
|
||||
smtp = ServerConfigEmbedded(smtp.host, smtp.port, smtp.security.name),
|
||||
authError = authError,
|
||||
)
|
||||
|
||||
internal fun AccountSettingsEntity.toDomain(): AccountSettings = AccountSettings(
|
||||
|
||||
@@ -380,3 +380,25 @@ val MIGRATION_18_19 = object : Migration(18, 19) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v19 -> v20: covering index for the unified-inbox summary scan (issue #187; preserves existing data
|
||||
* — a pure additive index, no column/table change or data transformation). The paged "All inboxes"
|
||||
* query [org.libremail.data.local.dao.MessageDao.pagingUnifiedFolderSummaries] filters
|
||||
* `folder = ? AND inInbox = 1 ORDER BY timestampMillis DESC`, but no index led with `folder`, so the
|
||||
* planner walked the whole table via `index_messages_timestampMillis` in timestamp order and filtered
|
||||
* `folder`/`inInbox` per row (a full `SCAN`, verified via `EXPLAIN QUERY PLAN`). The
|
||||
* `(folder, inInbox, timestampMillis)` index turns the two equality predicates into an index seek and
|
||||
* supplies the `timestampMillis` ordering, so the scan becomes a bounded `SEARCH … USING INDEX
|
||||
* index_messages_folder_inInbox_timestampMillis (folder=? AND inInbox=?)` with no temp B-tree sort.
|
||||
* `CREATE INDEX IF NOT EXISTS` is idempotent, and the name/columns match the Room `@Index` on
|
||||
* [org.libremail.data.local.entity.MessageEntity] so the migrated schema validates against 20.json.
|
||||
*/
|
||||
val MIGRATION_19_20 = object : Migration(19, 20) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_messages_folder_inInbox_timestampMillis` " +
|
||||
"ON `messages` (`folder`, `inInbox`, `timestampMillis`)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,16 @@ interface AccountDao {
|
||||
orderedIds.forEachIndexed { index, id -> setSortOrder(id, index) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a user-facing [AccountEntity.authError] on the account (issue #362), returning the number of
|
||||
* rows actually changed. The `authError IS NOT :message` guard makes it a **conditional** write: it
|
||||
* updates only when the stored value differs (including from NULL), so a caller reconciling the auth
|
||||
* circuit on every sync slice sets the error — and logs it — exactly once, never re-writing the same
|
||||
* message. Pass a non-null message to mark errored.
|
||||
*/
|
||||
@Query("UPDATE accounts SET authError = :message WHERE id = :id AND authError IS NOT :message")
|
||||
suspend fun setAuthError(id: String, message: String): Int
|
||||
|
||||
@Query("DELETE FROM accounts WHERE id = :id")
|
||||
suspend fun deleteById(id: String)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.libremail.data.local.entity.AccountSettingsEntity
|
||||
|
||||
@@ -18,4 +19,15 @@ interface AccountSettingsDao {
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(settings: AccountSettingsEntity)
|
||||
|
||||
/**
|
||||
* Reads [accountId]'s row (or null when absent), applies [transform], and writes the result — all in
|
||||
* one transaction so a per-field setter's read-modify-write can't interleave with a concurrent
|
||||
* setter and clobber the other field (issue #313). [transform] receives the stored entity, or null
|
||||
* when no row exists yet.
|
||||
*/
|
||||
@Transaction
|
||||
suspend fun readModifyWrite(accountId: String, transform: (AccountSettingsEntity?) -> AccountSettingsEntity) {
|
||||
upsert(transform(get(accountId)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,18 +15,6 @@ import org.libremail.data.local.entity.MessageSummary
|
||||
|
||||
@Dao
|
||||
interface MessageDao {
|
||||
/**
|
||||
* Mailbox-list projection ordered newest-first. Deliberately omits the large `body`/`isHtml`
|
||||
* columns: the list observes every cached message at once, and pulling full bodies through
|
||||
* SQLite's shared ~2 MB CursorWindow overflows it once enough large bodies are cached
|
||||
* (issue #51). Bodies are loaded lazily per-message via [getById] when a message is opened.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " +
|
||||
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages ORDER BY timestampMillis DESC",
|
||||
)
|
||||
fun observeSummaries(): Flow<List<MessageSummary>>
|
||||
|
||||
/**
|
||||
* Paged unified-inbox projection: folder-synced rows of [folder] across every account,
|
||||
* newest-first, as a Paging 3 [PagingSource] (issue #124). Room loads only the requested window
|
||||
|
||||
@@ -44,4 +44,32 @@ interface SignatureDao {
|
||||
clearDefault(accountId)
|
||||
markDefault(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts [signature], making it the account's default when it is the account's first — the count
|
||||
* and the insert run in one transaction so two concurrent first-creates can't both read "count 0"
|
||||
* and both become default (issue #313). [signature]'s own `isDefault` is ignored: this method
|
||||
* decides it from the current count.
|
||||
*/
|
||||
@Transaction
|
||||
suspend fun insertMakingFirstDefault(signature: SignatureEntity) {
|
||||
upsert(signature.copy(isDefault = countForAccount(signature.accountId) == 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes [id] and, when it was the account's default, promotes the account's first remaining
|
||||
* signature — both in one transaction so a crash between the delete and the promote can't leave an
|
||||
* account with signatures but no default (issue #313). No-op when [id] is absent. Returns the id of
|
||||
* the signature promoted to default, or null when nothing was promoted (id absent, the deleted row
|
||||
* wasn't the default, or no signatures remain).
|
||||
*/
|
||||
@Transaction
|
||||
suspend fun deletePromotingDefault(id: String): String? {
|
||||
val existing = getById(id) ?: return null
|
||||
delete(id)
|
||||
if (!existing.isDefault) return null
|
||||
val promoted = firstForAccount(existing.accountId) ?: return null
|
||||
markDefault(promoted.id)
|
||||
return promoted.id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,14 @@ data class AccountEntity(
|
||||
* a migrated one (the folders `specialUse` pattern).
|
||||
*/
|
||||
@ColumnInfo(defaultValue = "0") val sortOrder: Int = 0,
|
||||
/**
|
||||
* A user-facing sync/auth error that has halted this account, or null when healthy (issue #362). Set
|
||||
* to the "remove and re-add" message once the proactive auth circuit **latches** — the account's login
|
||||
* has failed enough consecutive times that a credential fix, not a retry, is required — so the account
|
||||
* list and the mailbox banner can surface it. Nullable with an implicit NULL default, so existing rows
|
||||
* migrate to "no error" and a fresh add (which rewrites the row) clears it.
|
||||
*/
|
||||
val authError: String? = null,
|
||||
)
|
||||
|
||||
/** Embedded host/port/security columns (prefixed per server in [AccountEntity]). */
|
||||
|
||||
@@ -11,7 +11,19 @@ import androidx.room.PrimaryKey
|
||||
// The (accountId, folder, uid) index serves the folder-scoped UID probes the backfill/reconcile
|
||||
// hot paths run on every page/sync: MIN(uid) (lowestSyncedUid) and the uid >= window bound
|
||||
// (deleteSyncedInWindowNotIn / syncedIdsBeyondCountInFolder).
|
||||
indices = [Index("accountId"), Index("timestampMillis"), Index("accountId", "folder", "uid")],
|
||||
//
|
||||
// The (folder, inInbox, timestampMillis) index serves the unified-inbox summary scan (issue #187):
|
||||
// MessageDao.pagingUnifiedFolderSummaries filters `folder = ? AND inInbox = 1 ORDER BY
|
||||
// timestampMillis DESC` with no folder-leading index, so it SCANned the whole table via
|
||||
// index_messages_timestampMillis and filtered per row. This index makes the two equalities an
|
||||
// index seek and supplies the timestampMillis ordering, turning the SCAN into a bounded SEARCH
|
||||
// with no temp B-tree sort (verified via EXPLAIN QUERY PLAN).
|
||||
indices = [
|
||||
Index("accountId"),
|
||||
Index("timestampMillis"),
|
||||
Index("accountId", "folder", "uid"),
|
||||
Index("folder", "inInbox", "timestampMillis"),
|
||||
],
|
||||
)
|
||||
data class MessageEntity(
|
||||
@PrimaryKey val id: String,
|
||||
|
||||
@@ -22,8 +22,11 @@ import org.libremail.data.sync.SyncScheduler
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.domain.repository.AccountRepository
|
||||
import org.libremail.mail.AuthThrottleGate
|
||||
import org.libremail.mail.ImapClient
|
||||
import org.libremail.notifications.MailNotifier
|
||||
import org.libremail.reporting.AppLog
|
||||
import org.libremail.reporting.accountLogRef
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -37,6 +40,7 @@ class AccountRepositoryImpl @Inject constructor(
|
||||
private val draftDao: DraftDao,
|
||||
private val credentialStore: CredentialStore,
|
||||
private val imapClient: ImapClient,
|
||||
private val authGate: AuthThrottleGate,
|
||||
private val syncScheduler: SyncScheduler,
|
||||
private val accountSettingsRepository: AccountSettingsRepository,
|
||||
private val mailNotifier: MailNotifier,
|
||||
@@ -52,11 +56,24 @@ class AccountRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun addImapAccount(account: Account, password: String): Result<List<String>> = runCatching {
|
||||
val folders = imapClient.listFolders(account.toImapParams(secret = password, useXoauth2 = false))
|
||||
val params = account.toImapParams(secret = password, useXoauth2 = false)
|
||||
// #362: a re-add is the ONE thing that clears a latched Yahoo/AOL auth circuit. Drop the in-memory
|
||||
// latch BEFORE the connection test so the fresh credential gets a clean login (an un-reset gate would
|
||||
// still refuse it); the account-row rewrite below then clears the persisted authError (a fresh domain
|
||||
// Account carries authError = null, and insertAtEnd's in-place update writes that null).
|
||||
authGate.onAccountReadded(params)
|
||||
val folders = imapClient.listFolders(params)
|
||||
// Persist the credential BEFORE inserting the account row (#403). Both LibreMailApplication's
|
||||
// push collector and IdleService.reconcileWatchers react to the *accounts* table; committing the
|
||||
// secret first guarantees any watcher that observes the new row can already resolve it, instead
|
||||
// of firing a transient "No stored credentials" IDLE miss on every account add. The credentials
|
||||
// table has no foreign key to accounts, so it can be written first; account_settings does (FK),
|
||||
// so ensureDefaults must still follow the account row.
|
||||
credentialStore.saveSecret(account.id, password)
|
||||
accountDao.insertAtEnd(account.toEntity())
|
||||
accountSettingsRepository.ensureDefaults(account.id)
|
||||
credentialStore.saveSecret(account.id, password)
|
||||
mailNotifier.ensureAccountChannel(account)
|
||||
AppLog.i(TAG, "IMAP account added ${accountLogRef(account.id)}; credential persisted before account row")
|
||||
syncScheduler.syncNow()
|
||||
syncScheduler.backfillNow() // start caching this account's full history in the background (#12)
|
||||
folders.map { it.fullName }
|
||||
@@ -68,11 +85,20 @@ class AccountRepositoryImpl @Inject constructor(
|
||||
authStateJson: String,
|
||||
): Result<List<String>> = runCatching {
|
||||
val account = Account.outlook(email)
|
||||
val folders = imapClient.listFolders(account.toImapParams(secret = accessToken, useXoauth2 = true))
|
||||
val params = account.toImapParams(secret = accessToken, useXoauth2 = true)
|
||||
// #362: clear any latched auth circuit on re-add before the connection test, exactly as
|
||||
// addImapAccount does — the account-row rewrite below clears the persisted authError.
|
||||
authGate.onAccountReadded(params)
|
||||
val folders = imapClient.listFolders(params)
|
||||
// Persist the durable AuthState BEFORE the account row (#403) — same ordering rationale as
|
||||
// addImapAccount: the push watchers observe the accounts table, so the secret must be committed
|
||||
// first for the newly-observed account to resolve. account_settings' FK still needs the row, so
|
||||
// ensureDefaults follows the insert.
|
||||
credentialStore.saveSecret(account.id, authStateJson)
|
||||
accountDao.insertAtEnd(account.toEntity())
|
||||
accountSettingsRepository.ensureDefaults(account.id)
|
||||
credentialStore.saveSecret(account.id, authStateJson)
|
||||
mailNotifier.ensureAccountChannel(account)
|
||||
AppLog.i(TAG, "Outlook account added ${accountLogRef(account.id)}; credential persisted before account row")
|
||||
syncScheduler.syncNow()
|
||||
syncScheduler.backfillNow() // start caching this account's full history in the background (#12)
|
||||
folders.map { it.fullName }
|
||||
@@ -113,4 +139,8 @@ class AccountRepositoryImpl @Inject constructor(
|
||||
if (accountId != null) backfillProgressDao.deleteForAccount(accountId) else backfillProgressDao.deleteAll()
|
||||
syncScheduler.backfillNow()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "AccountRepository"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,8 +39,12 @@ import org.libremail.data.local.toOutgoingAttachments
|
||||
import org.libremail.data.local.toOutgoingAttachmentsJson
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
import org.libremail.data.sync.GmailBandwidthTracker
|
||||
import org.libremail.data.sync.GmailSyncLimits
|
||||
import org.libremail.data.sync.InteractiveImapGate
|
||||
import org.libremail.data.sync.MailConnectionFactory
|
||||
import org.libremail.data.sync.SendScheduler
|
||||
import org.libremail.data.sync.logSafeFolderLabel
|
||||
import org.libremail.domain.model.Attachment
|
||||
import org.libremail.domain.model.Draft
|
||||
import org.libremail.domain.model.Folder
|
||||
@@ -56,6 +60,8 @@ import org.libremail.domain.model.UnreadCount
|
||||
import org.libremail.domain.model.sanitizeAttachmentName
|
||||
import org.libremail.domain.repository.MailRepository
|
||||
import org.libremail.mail.ImapClient
|
||||
import org.libremail.reporting.AppLog
|
||||
import org.libremail.reporting.accountLogRef
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
@@ -76,6 +82,8 @@ class MailRepositoryImpl @Inject constructor(
|
||||
private val accountSettingsRepository: AccountSettingsRepository,
|
||||
private val signatureRepository: SignatureRepository,
|
||||
private val attachmentUriGrants: AttachmentUriGrants,
|
||||
private val interactiveGate: InteractiveImapGate,
|
||||
private val bandwidthTracker: GmailBandwidthTracker,
|
||||
) : MailRepository {
|
||||
|
||||
// Application-lifetime scope for fire-and-forget server pushes that must outlive the caller — e.g.
|
||||
@@ -153,31 +161,54 @@ class MailRepositoryImpl @Inject constructor(
|
||||
|
||||
override suspend fun openMessage(id: String): Result<Message> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
// Route on the body-less projection: a cached, already-read message needs no account, no
|
||||
// credentials, and no network, so it skips the Keystore decrypt + DataStore read that
|
||||
// resolving connection params costs (issue #186). Only the fetch / SEEN-push branches below
|
||||
// pull the account and resolve params, and each does so lazily right where it is needed.
|
||||
val routing = messageDao.getRouting(id) ?: error("Message not found")
|
||||
if (!routing.bodyFetched || !routing.isRead) {
|
||||
val account = accountDao.getById(routing.accountId)?.toDomain()
|
||||
if (account != null && !routing.bodyFetched) {
|
||||
val params = connectionFactory.imapParamsFor(account)
|
||||
val content = imapClient.fetchBodyMarkingSeen(params, routing.folder, uidOf(id))
|
||||
messageDao.updateBody(id, content.body, content.isHtml, Snippet.of(content.body, content.isHtml))
|
||||
attachmentDao.replaceForMessage(id, content.attachments.map { it.toEntity(id) })
|
||||
messageDao.setRead(id, true)
|
||||
} else if (account != null) {
|
||||
// Optimistic, local-only: the reader can render as soon as this returns. The SEEN flag
|
||||
// still needs to reach the server, but that IMAP round trip (connection + STORE) must not
|
||||
// sit on this path (#148/#186) — the body/attachments are already fully local. Pushed on
|
||||
// backgroundScope, which outlives this call.
|
||||
val params = connectionFactory.imapParamsFor(account)
|
||||
messageDao.setRead(id, true)
|
||||
pushSeenFlagInBackground(params, routing.folder, id)
|
||||
// Signal an interactive fetch for the whole open (#355) so the continuous background backfill
|
||||
// parks at its next page boundary and this body fetch wins the account's IMAP throughput
|
||||
// instead of queuing behind the backfill storm (the reader's ~48s uncached-open stall). The
|
||||
// counter is released even if the fetch throws, and a purely-cached open holds it only for the
|
||||
// brief local read.
|
||||
interactiveGate.withInteractive {
|
||||
// Time the whole open so a debug report shows what the reader's spinner is waiting on — a
|
||||
// cached open is a local read; a first open blocks on the IMAP body fetch below (issue #358).
|
||||
val startNanos = System.nanoTime()
|
||||
// Route on the body-less projection: a cached, already-read message needs no account, no
|
||||
// credentials, and no network, so it skips the Keystore decrypt + DataStore read that
|
||||
// resolving connection params costs (issue #186). Only the fetch / SEEN-push branches below
|
||||
// pull the account and resolve params, and each does so lazily right where it is needed.
|
||||
val routing = messageDao.getRouting(id) ?: error("Message not found")
|
||||
val fetchedBody = !routing.bodyFetched
|
||||
if (!routing.bodyFetched || !routing.isRead) {
|
||||
val account = accountDao.getById(routing.accountId)?.toDomain()
|
||||
if (account != null && !routing.bodyFetched) {
|
||||
val params = connectionFactory.imapParamsFor(account)
|
||||
val content = imapClient.fetchBodyMarkingSeen(params, routing.folder, uidOf(id))
|
||||
messageDao.updateBody(
|
||||
id,
|
||||
content.body,
|
||||
content.isHtml,
|
||||
Snippet.of(content.body, content.isHtml),
|
||||
)
|
||||
attachmentDao.replaceForMessage(id, content.attachments.map { it.toEntity(id) })
|
||||
messageDao.setRead(id, true)
|
||||
} else if (account != null) {
|
||||
// Optimistic, local-only: the reader can render as soon as this returns. The SEEN
|
||||
// flag still needs to reach the server, but that IMAP round trip (connection +
|
||||
// STORE) must not sit on this path (#148/#186) — the body/attachments are already
|
||||
// fully local. Pushed on backgroundScope, which outlives this call.
|
||||
val params = connectionFactory.imapParamsFor(account)
|
||||
messageDao.setRead(id, true)
|
||||
pushSeenFlagInBackground(params, routing.folder, id)
|
||||
}
|
||||
}
|
||||
// The single full-body read, reserved for the value the reader actually renders (issue #186).
|
||||
val message = messageDao.getById(id)?.toDomain() ?: error("Message not found")
|
||||
// PII-free: hashed account ref, system-folder label only, plus the branch taken and ms.
|
||||
AppLog.i(
|
||||
READER_TAG,
|
||||
"openMessage ${accountLogRef(routing.accountId)} folder=${logSafeFolderLabel(routing.folder)} " +
|
||||
"fetchedBody=$fetchedBody took=${(System.nanoTime() - startNanos) / NANOS_PER_MS}ms",
|
||||
)
|
||||
message
|
||||
}
|
||||
// The single full-body read, reserved for the value the reader actually renders (issue #186).
|
||||
messageDao.getById(id)?.toDomain() ?: error("Message not found")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,35 +241,55 @@ class MailRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun inlineImages(messageId: String): List<InlineImage> = withContext(Dispatchers.IO) {
|
||||
// Resolve the message's account/folder once (body-less), then reuse the on-disk cache per cid:
|
||||
// image — no per-image message re-read or attachment re-query (the old downloadAttachment N+1, #186).
|
||||
val routing = messageDao.getRouting(messageId) ?: return@withContext emptyList()
|
||||
val parts = attachmentDao.getForMessage(messageId)
|
||||
parts.filter { it.contentId != null }.mapNotNull { row ->
|
||||
// Reuse the on-disk attachment cache (download once, then instant + offline). A failed
|
||||
// fetch just omits that image, leaving a broken <img> rather than failing the open.
|
||||
val file = runCatching {
|
||||
ensureAttachmentFile(messageId, routing.accountId, routing.folder, row.partIndex, row.filename)
|
||||
}.getOrNull() ?: return@mapNotNull null
|
||||
InlineImage(contentId = row.contentId!!, mimeType = row.mimeType, bytes = file.readBytes())
|
||||
// Inline-image loads are an interactive fetch too (#355): the reader is showing them, so backfill
|
||||
// must yield while they download. Released even if a per-image fetch throws (via withInteractive).
|
||||
interactiveGate.withInteractive {
|
||||
// Resolve the message's account/folder once (body-less), then reuse the on-disk cache per cid:
|
||||
// image — no per-image message re-read or attachment re-query (the old downloadAttachment N+1, #186).
|
||||
val routing = messageDao.getRouting(messageId)
|
||||
if (routing == null) {
|
||||
emptyList()
|
||||
} else {
|
||||
val parts = attachmentDao.getForMessage(messageId)
|
||||
parts.filter { it.contentId != null }.mapNotNull { row ->
|
||||
// Reuse the on-disk attachment cache (download once, then instant + offline). A failed
|
||||
// fetch just omits that image, leaving a broken <img> rather than failing the open.
|
||||
val file = runCatching {
|
||||
ensureAttachmentFile(messageId, routing.accountId, routing.folder, row.partIndex, row.filename)
|
||||
}.getOrNull()?.file ?: return@mapNotNull null
|
||||
InlineImage(contentId = row.contentId!!, mimeType = row.mimeType, bytes = file.readBytes())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun downloadAttachment(messageId: String, partIndex: Int): Result<File> =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val routing = messageDao.getRouting(messageId) ?: error("Message not found")
|
||||
val meta = attachmentDao.getForMessage(messageId).firstOrNull { it.partIndex == partIndex }
|
||||
ensureAttachmentFile(
|
||||
messageId,
|
||||
routing.accountId,
|
||||
routing.folder,
|
||||
partIndex,
|
||||
meta?.filename ?: "attachment",
|
||||
)
|
||||
// A user tapped an attachment (#355) — an interactive fetch backfill must yield to. Backfill's
|
||||
// own content prefetch deliberately does NOT come through here (it calls ensureAttachmentFile
|
||||
// directly), so background prefetch never raises the interactive gate against backfill itself.
|
||||
interactiveGate.withInteractive {
|
||||
val routing = messageDao.getRouting(messageId) ?: error("Message not found")
|
||||
val meta = attachmentDao.getForMessage(messageId).firstOrNull { it.partIndex == partIndex }
|
||||
ensureAttachmentFile(
|
||||
messageId,
|
||||
routing.accountId,
|
||||
routing.folder,
|
||||
partIndex,
|
||||
meta?.filename ?: "attachment",
|
||||
).file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [ensureAttachmentFile]'s outcome: the cached [file] plus the bytes actually pulled over the
|
||||
* network THIS call — `0` on a cache hit. [downloadedBytes] feeds Gmail's bandwidth accounting
|
||||
* (issue #361, see [prefetchMessage]); a cache hit costs nothing so it must not be double-counted.
|
||||
*/
|
||||
private class AttachmentFetch(val file: File, val downloadedBytes: Long)
|
||||
|
||||
/**
|
||||
* Returns the on-disk file for one attachment part, downloading and caching it on first use so it
|
||||
* then opens instantly and offline. Takes the message's already-resolved account/folder so a batch
|
||||
@@ -251,16 +302,16 @@ class MailRepositoryImpl @Inject constructor(
|
||||
folder: String,
|
||||
partIndex: Int,
|
||||
filename: String,
|
||||
): File {
|
||||
): AttachmentFetch {
|
||||
val target = attachmentFile(messageId, partIndex, filename)
|
||||
// Reuse a previously downloaded (or pre-fetched) file so it opens instantly and offline.
|
||||
if (target.exists() && target.length() > 0L) return target
|
||||
if (target.exists() && target.length() > 0L) return AttachmentFetch(target, downloadedBytes = 0L)
|
||||
val account = accountDao.getById(accountId)?.toDomain() ?: error("Account not found")
|
||||
val params = connectionFactory.imapParamsFor(account)
|
||||
val downloaded = imapClient.fetchAttachment(params, folder, uidOf(messageId), partIndex)
|
||||
target.parentFile?.mkdirs()
|
||||
target.outputStream().use { it.write(downloaded.bytes) }
|
||||
return target
|
||||
return AttachmentFetch(target, downloadedBytes = downloaded.bytes.size.toLong())
|
||||
}
|
||||
|
||||
override suspend fun downloadedAttachmentParts(messageId: String): Set<Int> = withContext(Dispatchers.IO) {
|
||||
@@ -276,16 +327,39 @@ class MailRepositoryImpl @Inject constructor(
|
||||
override suspend fun prefetchMessage(messageId: String): Result<Unit> = runCatching {
|
||||
val routing = messageDao.getRouting(messageId) ?: return@runCatching
|
||||
val account = accountDao.getById(routing.accountId)?.toDomain() ?: return@runCatching
|
||||
// Bytes actually pulled over the network this call (0 on an all-cache-hit prefetch), fed to
|
||||
// Gmail's daily download-budget tracker below (issue #361) — the proactive pacing that composes
|
||||
// with #360's reactive AccountThrottleGate and #356's BackfillPacer without modifying either.
|
||||
var downloadedBytes = 0L
|
||||
// Cache the body (peek, so prefetching never marks the message read) and its attachment metadata.
|
||||
if (!routing.bodyFetched) {
|
||||
val params = connectionFactory.imapParamsFor(account)
|
||||
val content = imapClient.fetchBodyPeek(params, routing.folder, uidOf(messageId))
|
||||
messageDao.updateBody(messageId, content.body, content.isHtml, Snippet.of(content.body, content.isHtml))
|
||||
attachmentDao.replaceForMessage(messageId, content.attachments.map { it.toEntity(messageId) })
|
||||
downloadedBytes += content.body.toByteArray(Charsets.UTF_8).size.toLong()
|
||||
}
|
||||
// Auto-download every attachment's bytes into the persistent per-part cache (skips ones present).
|
||||
// This is BACKGROUND work driven by the backfill, so it goes straight to ensureAttachmentFile and
|
||||
// deliberately bypasses downloadAttachment's interactive gate (#355): prefetch must not signal an
|
||||
// interactive fetch, or backfill would park behind (yield to) its own prefetch. The routing is
|
||||
// already resolved here, so this also skips re-reading it per part.
|
||||
attachmentDao.getForMessage(messageId).forEach { attachment ->
|
||||
downloadAttachment(messageId, attachment.partIndex)
|
||||
runCatching {
|
||||
ensureAttachmentFile(
|
||||
messageId,
|
||||
routing.accountId,
|
||||
routing.folder,
|
||||
attachment.partIndex,
|
||||
attachment.filename,
|
||||
)
|
||||
}.getOrNull()?.let { downloadedBytes += it.downloadedBytes }
|
||||
}
|
||||
// Gmail-specific bandwidth accounting (issue #361): only tracked for Gmail, since that is the
|
||||
// only provider whose daily download budget is enforced today (see GmailSyncLimits.appliesTo /
|
||||
// MailBackfiller.prefetchIfEnabled / MailSyncer.prefetchIfEnabled for the deferral this feeds).
|
||||
if (downloadedBytes > 0L && GmailSyncLimits.appliesTo(account)) {
|
||||
bandwidthTracker.recordDownload(account.id, downloadedBytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,16 +397,16 @@ class MailRepositoryImpl @Inject constructor(
|
||||
moveByRole(ids, FolderRole.TRASH, fallbackExpunge = true)
|
||||
|
||||
override suspend fun expunge(ids: List<String>): Result<Unit> = runCatching {
|
||||
val routings = messageDao.getRoutingByIds(ids)
|
||||
messageDao.deleteByIds(ids) // optimistic
|
||||
val routings = messageDao.getRoutingByIdsChunked(ids)
|
||||
messageDao.deleteByIdsChunked(ids) // optimistic
|
||||
forEachAccountFolder(routings) { params, folder, group ->
|
||||
imapClient.deleteMessages(params, folder, group.map { uidOf(it.id) })
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun moveToFolder(ids: List<String>, destFolderFullName: String): Result<Unit> = runCatching {
|
||||
val routings = messageDao.getRoutingByIds(ids)
|
||||
messageDao.deleteByIds(ids) // optimistic
|
||||
val routings = messageDao.getRoutingByIdsChunked(ids)
|
||||
messageDao.deleteByIdsChunked(ids) // optimistic
|
||||
forEachAccountFolder(routings) { params, folder, group ->
|
||||
if (folder != destFolderFullName) {
|
||||
imapClient.moveMessages(params, folder, group.map { uidOf(it.id) }, destFolderFullName)
|
||||
@@ -341,35 +415,39 @@ class MailRepositoryImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override suspend fun buildReplyDraft(messageId: String, mode: ReplyMode): Result<String> = runCatching {
|
||||
val routing = messageDao.getRouting(messageId) ?: error("Message not found")
|
||||
val account = accountDao.getById(routing.accountId)?.toDomain() ?: error("Account not found")
|
||||
val params = connectionFactory.imapParamsFor(account)
|
||||
val context = imapClient.fetchForReply(params, routing.folder, uidOf(messageId))
|
||||
val content = ReplyBuilder.build(context, mode, account.email)
|
||||
// Bake the sending account's default signature into the reply/forward body — above the quoted
|
||||
// original — so it round-trips as part of the draft (compose won't re-append for drafts). Both
|
||||
// the plaintext and HTML forms are stored so the reply can go out as multipart/alternative.
|
||||
val settings = accountSettingsRepository.get(routing.accountId)
|
||||
val sig = if (settings.signatureEnabled) {
|
||||
SignatureBlock.of(signatureRepository.getDefault(routing.accountId))
|
||||
} else {
|
||||
SignatureBlock.EMPTY
|
||||
// Fetching the original for a reply/forward is an interactive fetch (#355) — the user is waiting on
|
||||
// the compose screen — so backfill yields to it. Released even if fetchForReply throws.
|
||||
interactiveGate.withInteractive {
|
||||
val routing = messageDao.getRouting(messageId) ?: error("Message not found")
|
||||
val account = accountDao.getById(routing.accountId)?.toDomain() ?: error("Account not found")
|
||||
val params = connectionFactory.imapParamsFor(account)
|
||||
val context = imapClient.fetchForReply(params, routing.folder, uidOf(messageId))
|
||||
val content = ReplyBuilder.build(context, mode, account.email)
|
||||
// Bake the sending account's default signature into the reply/forward body — above the quoted
|
||||
// original — so it round-trips as part of the draft (compose won't re-append for drafts). Both
|
||||
// the plaintext and HTML forms are stored so the reply can go out as multipart/alternative.
|
||||
val settings = accountSettingsRepository.get(routing.accountId)
|
||||
val sig = if (settings.signatureEnabled) {
|
||||
SignatureBlock.of(signatureRepository.getDefault(routing.accountId))
|
||||
} else {
|
||||
SignatureBlock.EMPTY
|
||||
}
|
||||
val draftId = UUID.randomUUID().toString()
|
||||
saveDraft(
|
||||
Draft(
|
||||
id = draftId,
|
||||
accountId = routing.accountId,
|
||||
to = content.to,
|
||||
cc = content.cc,
|
||||
subject = content.subject,
|
||||
body = sig.plain + content.body,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
bodyHtml = sig.html + content.bodyHtml,
|
||||
attachments = emptyList(),
|
||||
),
|
||||
)
|
||||
draftId
|
||||
}
|
||||
val draftId = UUID.randomUUID().toString()
|
||||
saveDraft(
|
||||
Draft(
|
||||
id = draftId,
|
||||
accountId = routing.accountId,
|
||||
to = content.to,
|
||||
cc = content.cc,
|
||||
subject = content.subject,
|
||||
body = sig.plain + content.body,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
bodyHtml = sig.html + content.bodyHtml,
|
||||
attachments = emptyList(),
|
||||
),
|
||||
)
|
||||
draftId
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -379,8 +457,8 @@ class MailRepositoryImpl @Inject constructor(
|
||||
*/
|
||||
private suspend fun moveByRole(ids: List<String>, role: FolderRole, fallbackExpunge: Boolean): Result<Unit> =
|
||||
runCatching {
|
||||
val routings = messageDao.getRoutingByIds(ids)
|
||||
messageDao.deleteByIds(ids) // optimistic
|
||||
val routings = messageDao.getRoutingByIdsChunked(ids)
|
||||
messageDao.deleteByIdsChunked(ids) // optimistic
|
||||
val destByAccount = routings.map { it.accountId }.distinct()
|
||||
.associateWith { resolveRoleFolder(it, role) }
|
||||
forEachAccountFolder(routings) { params, folder, group ->
|
||||
@@ -535,9 +613,19 @@ class MailRepositoryImpl @Inject constructor(
|
||||
|
||||
private const val SEARCH_LIMIT = 50
|
||||
|
||||
/** Perf-breadcrumb tag and ns→ms divisor for the reader-open timing (issue #358). */
|
||||
private const val READER_TAG = "MailReader"
|
||||
private const val NANOS_PER_MS = 1_000_000L
|
||||
|
||||
/** Rows per page for the unified inbox (issue #124) — a page is a few screenfuls of message rows. */
|
||||
private const val MAILBOX_PAGE_SIZE = 40
|
||||
|
||||
/**
|
||||
* Ids per `IN (:ids)` query in the batch move/delete/expunge paths, kept under SQLite's 999
|
||||
* host-parameter limit on older Android (matches [org.libremail.data.sync.MailPruner]'s DELETE chunk).
|
||||
*/
|
||||
private const val SQL_IN_CHUNK = 500
|
||||
|
||||
/** Attempts for the background best-effort SEEN-flag push before giving up silently (issue #148). */
|
||||
private const val SEEN_FLAG_PUSH_MAX_ATTEMPTS = 3
|
||||
|
||||
@@ -547,6 +635,20 @@ private const val SEEN_FLAG_RETRY_BACKOFF_MS = 2_000L
|
||||
/** Message id is "<accountId>:<uid>"; the uid is the trailing segment. */
|
||||
private fun uidOf(id: String): String = id.substringAfterLast(':')
|
||||
|
||||
/**
|
||||
* [MessageDao.getRoutingByIds] over an arbitrarily large [ids] list, chunked so the expanded `IN (:ids)`
|
||||
* never exceeds SQLite's host-parameter limit (999 on older Android). The batch move/delete/expunge
|
||||
* callers are bounded by the multi-select cap today, but chunking removes the latent
|
||||
* `SQLITE_MAX_VARIABLE_NUMBER` crash the same way [org.libremail.data.sync.MailPruner] does (issue #313).
|
||||
*/
|
||||
private suspend fun MessageDao.getRoutingByIdsChunked(ids: List<String>): List<MessageRouting> =
|
||||
ids.chunked(SQL_IN_CHUNK).flatMap { getRoutingByIds(it) }
|
||||
|
||||
/** [MessageDao.deleteByIds] chunked under SQLite's host-parameter limit (see [getRoutingByIdsChunked]). */
|
||||
private suspend fun MessageDao.deleteByIdsChunked(ids: List<String>) {
|
||||
ids.chunked(SQL_IN_CHUNK).forEach { deleteByIds(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the SQL `LIKE` pattern the paged-search DAO queries take (issue #214), preserving the old
|
||||
* `matchesSearch` literal-substring semantics: escape the LIKE metacharacters (`\ % _`) — the `\`
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user