Compare commits
514
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3256e036d1 | ||
|
|
46ca019aa8 | ||
|
|
0045890148 | ||
|
|
17985cb04c | ||
|
|
f049898be1 | ||
|
|
d926320123 | ||
|
|
2df16a8293 | ||
|
|
950aabb470 | ||
|
|
58447d7d12 | ||
|
|
abfa60ba86 | ||
|
|
4c3937edde | ||
|
|
cb9e0e3e54 | ||
|
|
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 | ||
|
|
0e451fc80e | ||
|
|
e4457cfec1 | ||
|
|
2e04934e19 | ||
|
|
7bd909882f | ||
|
|
9110915629 | ||
|
|
def89c4e2a | ||
|
|
8cb57cba50 | ||
|
|
1649154ac4 | ||
|
|
5125a6674c | ||
|
|
c3d335a753 | ||
|
|
42b8bcc91e | ||
|
|
2289f402e5 | ||
|
|
97b782ecb2 | ||
|
|
61dc35e290 | ||
|
|
cbf284b5f5 | ||
|
|
c645e0e1fe | ||
|
|
b33f73273d | ||
|
|
61437e8670 | ||
|
|
1d4bd6346c | ||
|
|
2b616df3e0 | ||
|
|
f821e0274e | ||
|
|
3062a3a3d9 | ||
|
|
205f1f6de1 | ||
|
|
6245533368 | ||
|
|
c98eac6c5b | ||
|
|
edbb1eb839 | ||
|
|
df57dcd18d | ||
|
|
6810cc5390 | ||
|
|
5e2d65d91a | ||
|
|
e80f8333f4 | ||
|
|
6de885d53e | ||
|
|
b95ae2e7eb | ||
|
|
f72c66d291 | ||
|
|
da2963aa86 | ||
|
|
64f4c64936 | ||
|
|
005f8a3aca | ||
|
|
659f6e1d74 | ||
|
|
cae11233f3 | ||
|
|
ce9e54f704 | ||
|
|
8904351a96 | ||
|
|
8230dd0341 | ||
|
|
b64c7f8dda | ||
|
|
0e42358d27 | ||
|
|
b165f72e3a | ||
|
|
5c34b01bf0 | ||
|
|
7966efde2f | ||
|
|
1af6ad3d52 | ||
|
|
a6c24f3cea | ||
|
|
f4a95a6051 | ||
|
|
a8399228f6 | ||
|
|
991f9b77e4 | ||
|
|
6ff43c1035 | ||
|
|
fdb45131de | ||
|
|
0b67cb952a | ||
|
|
bdb49f996f | ||
|
|
b78921cd0b | ||
|
|
15f53633fa | ||
|
|
191e802eac | ||
|
|
5a3669f017 | ||
|
|
eda5103da2 | ||
|
|
0b44276909 | ||
|
|
e587ba0d82 | ||
|
|
24b9572179 | ||
|
|
17d7135f59 | ||
|
|
165699d950 | ||
|
|
fc78d81886 | ||
|
|
6151fea4d1 | ||
|
|
633b3a94e2 | ||
|
|
e006600424 | ||
|
|
6bde35507b | ||
|
|
e57ba70fb1 | ||
|
|
46c4110b19 | ||
|
|
65ff45ec31 | ||
|
|
e4a27944bd | ||
|
|
11490b82fe | ||
|
|
9ab7c22c48 | ||
|
|
44c662a0fd | ||
|
|
c38db3b213 | ||
|
|
ddc8c30ade | ||
|
|
1e2554e86b | ||
|
|
fc6a79ddb0 | ||
|
|
1ff9d32b80 | ||
|
|
36cbbe7b5d | ||
|
|
264cad83f8 | ||
|
|
a3b49737b9 | ||
|
|
bc45c9f3c9 | ||
|
|
2498725f83 | ||
|
|
148780735f | ||
|
|
1d84dd437f | ||
|
|
f98425667d | ||
|
|
52636fd459 | ||
|
|
f537717c23 | ||
|
|
049c11f5f0 | ||
|
|
cf31f99ae1 | ||
|
|
8af6882234 | ||
|
|
5e581d4b52 | ||
|
|
dea6804619 | ||
|
|
e190dc3191 | ||
|
|
64d89d1e2a | ||
|
|
153f8784a7 | ||
|
|
fa0c7c48c8 | ||
|
|
ea470e065c | ||
|
|
1ed94f9ea0 | ||
|
|
d29f146bd2 | ||
|
|
817f0dd642 | ||
|
|
f16e1a19be | ||
|
|
d8a37d280c | ||
|
|
be89bcc6d9 | ||
|
|
664d79a427 | ||
|
|
53f600369b | ||
|
|
ac061d3138 | ||
|
|
0d3b0e2a73 | ||
|
|
0ceaf97754 | ||
|
|
5705e0e7f8 | ||
|
|
a297e38304 | ||
|
|
0fd3954f79 | ||
|
|
579d4864b9 | ||
|
|
0205bff8a0 | ||
|
|
f714352a08 | ||
|
|
6f3daae4b2 | ||
|
|
5b5e48054d | ||
|
|
358dcc788e | ||
|
|
0a462fda0c | ||
|
|
ba2dc7fcf6 | ||
|
|
fb802a8556 | ||
|
|
7543350330 | ||
|
|
dad3345ce0 | ||
|
|
0948d1406e | ||
|
|
9d8581a551 | ||
|
|
b6e9c12dab | ||
|
|
7485fecfea | ||
|
|
249381b249 | ||
|
|
fa64c7b334 | ||
|
|
e7158f344d | ||
|
|
50db69b060 | ||
|
|
93ca14bc18 | ||
|
|
5217c5b7ad | ||
|
|
d903ded2d4 | ||
|
|
05b43f4510 | ||
|
|
ac5ed162ae | ||
|
|
b5a29a0450 | ||
|
|
5ecc2401e3 | ||
|
|
8e7b82f86a | ||
|
|
a5b26632ac | ||
|
|
de04bc2cae | ||
|
|
91e267bb44 | ||
|
|
0b05df6bef | ||
|
|
f33b111256 | ||
|
|
bef197b628 | ||
|
|
3fedc854de | ||
|
|
40067a069f | ||
|
|
b384647a9f | ||
|
|
29e22042cb | ||
|
|
596b387697 | ||
|
|
a8784d2864 | ||
|
|
1774a33158 | ||
|
|
4fb9db2a51 | ||
|
|
0e3ffd58ef | ||
|
|
fecd4d67dc | ||
|
|
4cead4d520 | ||
|
|
8779b439f7 | ||
|
|
3da0c3a580 | ||
|
|
f7285a0152 | ||
|
|
402e19a707 | ||
|
|
944bd45a1b | ||
|
|
6333dd4511 | ||
|
|
cb59417f59 | ||
|
|
5a114517dc | ||
|
|
be0e699fcc | ||
|
|
99be90ef48 | ||
|
|
cc77611d43 | ||
|
|
056ac69185 | ||
|
|
853b40f419 | ||
|
|
8ab9c7ec29 | ||
|
|
1e3cd7cae5 | ||
|
|
ef77a7559f | ||
|
|
78b7ebf777 | ||
|
|
4c852a1083 | ||
|
|
339b4017ef | ||
|
|
02a0082c7f | ||
|
|
8eac5807bc | ||
|
|
e03788da6d | ||
|
|
f01ec66e85 | ||
|
|
9a3e3aa42d | ||
|
|
50f8cf2e2b | ||
|
|
b5997f75fe | ||
|
|
c707fd29e5 | ||
|
|
d3f8136f12 | ||
|
|
1ea50ea316 | ||
|
|
81a362c30a | ||
|
|
873f45ff33 | ||
|
|
77731e06e4 | ||
|
|
e2606b7751 | ||
|
|
e823f9b17e | ||
|
|
f2cbabb59a | ||
|
|
c86e8acf2f | ||
|
|
4a3a360471 | ||
|
|
1e738a6670 | ||
|
|
e600122cc0 | ||
|
|
3903a4b4b1 | ||
|
|
321d90432b | ||
|
|
d52cd2b4bf | ||
|
|
d39617e5d8 | ||
|
|
f10129c4c6 | ||
|
|
78fa555d87 | ||
|
|
92a85c1f9e | ||
|
|
b3e3ddd59d | ||
|
|
4260a304bd | ||
|
|
276e08840a | ||
|
|
c5dd42b94b | ||
|
|
9cd136f81b | ||
|
|
a7ab6275ac | ||
|
|
7b52a9682d | ||
|
|
aad9d99ffc | ||
|
|
7f623167e7 | ||
|
|
535cbcb49a | ||
|
|
9a69d175a8 | ||
|
|
05dde9399c | ||
|
|
df6cbd4a76 | ||
|
|
cbc9fc62ef | ||
|
|
7e69fcf185 | ||
|
|
aae4f2218b | ||
|
|
2a2db6d0eb | ||
|
|
34de875601 | ||
|
|
3168bfa59e | ||
|
|
b76447b8cf | ||
|
|
2a07b2423e | ||
|
|
48fe8d2fb0 | ||
|
|
6d75bf5c6d | ||
|
|
e2da97a2ff | ||
|
|
82ed7dad1a | ||
|
|
a62d5edfdd | ||
|
|
7b5819021a | ||
|
|
6eb1a74a5b | ||
|
|
cbeac0f26f | ||
|
|
541f4809cb | ||
|
|
68a225ed9c | ||
|
|
440d1e9c62 | ||
|
|
5cfd1b4065 | ||
|
|
0e2be05be7 | ||
|
|
ae977c7539 | ||
|
|
78c0fded29 | ||
|
|
3728efb55e | ||
|
|
c1b80c3668 | ||
|
|
592a797dd0 | ||
|
|
7784dbf3ba | ||
|
|
364fe32aaa | ||
|
|
1a0d140c03 | ||
|
|
69cce168ef | ||
|
|
7d7fef7b90 | ||
|
|
54c941d771 | ||
|
|
1a8d6e1f7b |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: preflight
|
||||
description: Run LibreMail's fast CI gate locally (assembleDebug + testDebugUnitTest + lintDebug + ktlintCheck + detekt) before pushing or opening a PR. Mirrors the merge gate; does NOT run emulator E2E. 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,6 +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 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`. 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.
|
||||
- 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
|
||||
|
||||
@@ -20,24 +38,76 @@ 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
|
||||
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
|
||||
that just changed signature) passes the rest of this gate locally yet fails CI.
|
||||
|
||||
`ktlintCheck` + `detekt` are exactly what CI's **Static analysis** job runs — they cover the
|
||||
`test`/`androidTest` source sets that `lintDebug` does not, so a style violation there fails the
|
||||
merge gate even when the build and lint are green. Add `--continue` to any command (e.g.
|
||||
`:app:ktlintCheck :app:detekt --continue`) to collect every failure in one pass instead of
|
||||
stopping at the first.
|
||||
|
||||
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 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
|
||||
`app/build.gradle.kts`), so there is no `api37DebugAndroidTest` task. The script hand-provisions
|
||||
it essentially the way CI's `e2e-preview` job does — installs the image with `sdkmanager`,
|
||||
creates the AVD with `avdmanager`, cold-boots it headless, waits for `sys.boot_completed`, runs
|
||||
`:app:connectedDebugAndroidTest`, then kills the emulator and deletes the AVD. The emulator flags
|
||||
match `e2e-preview` with one deliberate local exception: the **GPU mode**. CI uses
|
||||
`-gpu swiftshader_indirect` (software rendering, deterministic on a headless CI runner); the
|
||||
local run uses `-gpu auto-no-window`, which renders on the host GPU — faster, and the mode that
|
||||
boots cleanly on a dev machine.
|
||||
|
||||
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").
|
||||
- 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/`
|
||||
- Do **not** run emulator/E2E (`connectedDebugAndroidTest`) here — that's CI's job unless the
|
||||
user explicitly asks.
|
||||
- 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.
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Hand-provision an API 37 (Android 17, preview) emulator, run LibreMail's instrumented/E2E
|
||||
suite against it, then tear the emulator and AVD down. Invoked by the /preflight skill as the
|
||||
third (api37) E2E step, alongside the api35/api36 Gradle Managed Devices.
|
||||
|
||||
WHY THIS SCRIPT EXISTS
|
||||
----------------------
|
||||
API 37's only published system image is the nonstandard "android-37.0" / google_apis_ps16k
|
||||
(16 KB page size) pairing. AGP's Gradle Managed Device DSL can only build an
|
||||
"android-<apiLevel:Int>" package id (apiLevel = 37 -> "android-37") or an
|
||||
"android-<apiPreview:codename>" one -- neither resolves to "android-37.0" -- so there is NO
|
||||
api37DebugAndroidTest task to run. This script custom-provisions the emulator with
|
||||
sdkmanager / avdmanager / emulator directly, closely mirroring CI's `e2e-preview` job in
|
||||
.github/workflows/ci.yml (same system image string, same provisioning/boot sequence, same
|
||||
emulator flags EXCEPT the GPU mode -- CI uses `-gpu swiftshader_indirect` for headless
|
||||
determinism, while this local run uses `-gpu auto-no-window` to render on the host GPU, which
|
||||
is faster; see start_emulator). Keep the two in lockstep on everything but that GPU flag: when a
|
||||
stable, GMD-compatible API 37 image ships, delete this script, fold api37 into
|
||||
testOptions.managedDevices, and fold 37 into CI's `e2e` matrix (dropping the `e2e-preview` job).
|
||||
|
||||
Pure standard library, cross-platform (Windows / Linux / macOS): tool paths and executable
|
||||
suffixes are resolved per-OS, and `.bat` launchers are wrapped through `cmd /c` on Windows.
|
||||
|
||||
HYPERVISOR REQUIREMENT
|
||||
----------------------
|
||||
The emulator boots with `-accel on`, so it needs a FREE hardware hypervisor (Intel VT-x / AMD-V,
|
||||
exposed as WHPX on Windows, KVM on Linux, HVF on macOS). If VirtualBox, Hyper-V, WSL2, Docker
|
||||
Desktop, or another emulator is holding it, `-accel on` fails or the AVD hangs at 0% CPU and never
|
||||
reaches sys.boot_completed. Shut those down before running preflight.
|
||||
|
||||
JDK: the final Gradle step needs a JDK 17-21 daemon (AGP 9.2 fails on JDK 25+), same as the rest
|
||||
of preflight -- point JAVA_HOME at a 17-21 JDK before invoking.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# Defaults mirror .github/workflows/ci.yml e2e-preview (env.API37_IMAGE, env.ANDROID_PLATFORM,
|
||||
# env.ANDROID_BUILD_TOOLS) and its avdmanager invocation. Do not diverge without updating ci.yml.
|
||||
API37_IMAGE = "system-images;android-37.0;google_apis_ps16k;x86_64"
|
||||
PLATFORM_PKG = "platforms;android-37.0"
|
||||
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 ""
|
||||
EXE = ".exe" if IS_WINDOWS else ""
|
||||
|
||||
|
||||
def cmd(tool: str, *args: str) -> list[str]:
|
||||
"""Build an argv list, wrapping Windows `.bat`/`.cmd` launchers through `cmd /c`."""
|
||||
if IS_WINDOWS and tool.lower().endswith((".bat", ".cmd")):
|
||||
return ["cmd", "/c", tool, *args]
|
||||
return [tool, *args]
|
||||
|
||||
|
||||
def find_sdk_root() -> str:
|
||||
candidates = [os.environ.get("ANDROID_SDK_ROOT"), os.environ.get("ANDROID_HOME")]
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
local = os.environ.get("LOCALAPPDATA")
|
||||
if local:
|
||||
candidates.append(os.path.join(local, "Android", "Sdk"))
|
||||
elif system == "Darwin":
|
||||
candidates.append(os.path.expanduser("~/Library/Android/sdk"))
|
||||
else:
|
||||
candidates.append(os.path.expanduser("~/Android/Sdk"))
|
||||
for candidate in candidates:
|
||||
if candidate and os.path.isdir(candidate):
|
||||
return os.path.abspath(candidate)
|
||||
raise RuntimeError(
|
||||
"Android SDK not found. Set ANDROID_SDK_ROOT (or ANDROID_HOME) to your SDK location."
|
||||
)
|
||||
|
||||
|
||||
def resolve_tool(sdk_root: str, rel_paths: list[list[str]], name: str) -> str:
|
||||
for rel in rel_paths:
|
||||
path = os.path.join(sdk_root, *rel)
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
raise RuntimeError(
|
||||
f"Could not find {name} under {sdk_root}. "
|
||||
"Install the Android SDK command-line tools + emulator."
|
||||
)
|
||||
|
||||
|
||||
def run_sdkmanager(sdkmanager: str, args: list[str]) -> None:
|
||||
# Feed a stream of "y" so any unaccepted (incl. preview) license prompt is auto-accepted; this
|
||||
# is the non-interactive equivalent of the CI runner having licenses pre-accepted.
|
||||
result = subprocess.run(cmd(sdkmanager, *args), input="y\n" * 50, text=True, check=False)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"sdkmanager failed (exit {result.returncode}) for: {' '.join(args)}")
|
||||
|
||||
|
||||
def create_avd(avdmanager: str, emulator: str) -> None:
|
||||
print(f"Creating AVD '{AVD_NAME}' from {API37_IMAGE} (device: {DEVICE_PROFILE})...")
|
||||
# "no" answers avdmanager's "create a custom hardware profile?" prompt, mirroring CI.
|
||||
result = subprocess.run(
|
||||
cmd(avdmanager, "create", "avd", "-n", AVD_NAME, "-k", API37_IMAGE,
|
||||
"-d", DEVICE_PROFILE, "--force"),
|
||||
input="no\n", text=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"avdmanager create avd failed (exit {result.returncode}).")
|
||||
print("AVDs visible to the emulator:")
|
||||
subprocess.run(cmd(emulator, "-list-avds"), check=False)
|
||||
|
||||
|
||||
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 (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", 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:
|
||||
proc = subprocess.Popen(cmd(emulator, *flags), stdout=log, stderr=subprocess.STDOUT)
|
||||
finally:
|
||||
log.close() # the child has inherited its own fd; the parent's copy is no longer needed
|
||||
return proc
|
||||
|
||||
|
||||
def wait_for_boot(adb: str, proc: subprocess.Popen, timeout: int) -> bool:
|
||||
subprocess.run(cmd(adb, "start-server"), check=False)
|
||||
deadline = time.monotonic() + timeout
|
||||
|
||||
# Phase 1 -- the literal `adb wait-for-device`, bounded so a dead emulator can't hang the run
|
||||
# (returns as soon as the emulator registers). Mirrors CI's `adb wait-for-device`.
|
||||
try:
|
||||
subprocess.run(cmd(adb, "wait-for-device"), timeout=max(1, deadline - time.monotonic()),
|
||||
check=False)
|
||||
except subprocess.TimeoutExpired:
|
||||
return False
|
||||
if proc.poll() is not None:
|
||||
return False
|
||||
|
||||
# Phase 2 -- poll sys.boot_completed until it flips to 1 (mirrors CI's getprop loop).
|
||||
while time.monotonic() < deadline:
|
||||
if proc.poll() is not None:
|
||||
print(f"Emulator process exited during boot (code {proc.returncode}).",
|
||||
file=sys.stderr)
|
||||
return False
|
||||
out = subprocess.run(cmd(adb, "shell", "getprop", "sys.boot_completed"),
|
||||
capture_output=True, text=True, check=False)
|
||||
if out.stdout.strip() == "1":
|
||||
return True
|
||||
time.sleep(2)
|
||||
return False
|
||||
|
||||
|
||||
def stop_emulator(adb: str | None, proc: subprocess.Popen | None) -> None:
|
||||
if adb:
|
||||
subprocess.run(cmd(adb, "emu", "kill"), check=False,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
time.sleep(2)
|
||||
if proc and proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
def tail(path: Path, lines: int = 80) -> None:
|
||||
try:
|
||||
with open(path, "r", errors="replace") as handle:
|
||||
content = handle.readlines()[-lines:]
|
||||
print("--- emulator.log (tail) ---")
|
||||
print("".join(content))
|
||||
except OSError:
|
||||
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.")
|
||||
parser.add_argument("--boot-timeout", type=int, default=BOOT_TIMEOUT,
|
||||
help="Seconds to wait for the emulator to reach sys.boot_completed.")
|
||||
args = parser.parse_args()
|
||||
|
||||
sdk_root = find_sdk_root()
|
||||
print(f"Using Android SDK at: {sdk_root}")
|
||||
sdkmanager = resolve_tool(sdk_root, [
|
||||
["cmdline-tools", "latest", "bin", "sdkmanager" + BAT],
|
||||
["cmdline-tools", "bin", "sdkmanager" + BAT],
|
||||
["tools", "bin", "sdkmanager" + BAT],
|
||||
], "sdkmanager")
|
||||
avdmanager = resolve_tool(sdk_root, [
|
||||
["cmdline-tools", "latest", "bin", "avdmanager" + BAT],
|
||||
["cmdline-tools", "bin", "avdmanager" + BAT],
|
||||
["tools", "bin", "avdmanager" + BAT],
|
||||
], "avdmanager")
|
||||
|
||||
# Pin ANDROID_AVD_HOME so avdmanager (writes it) and the emulator (reads it) agree on the AVD
|
||||
# dir -- the same fix CI's e2e-preview applies to avoid "Unknown AVD name [api37]".
|
||||
avd_home = Path.home() / ".android" / "avd"
|
||||
avd_home.mkdir(parents=True, exist_ok=True)
|
||||
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:
|
||||
# 1. Install the SDK platform, build-tools, platform-tools, emulator, and preview image.
|
||||
print(f"Installing SDK packages + API 37 preview system image ({API37_IMAGE})...")
|
||||
run_sdkmanager(sdkmanager, ["--licenses"])
|
||||
run_sdkmanager(sdkmanager, [PLATFORM_PKG, BUILD_TOOLS, "platform-tools", "emulator",
|
||||
API37_IMAGE])
|
||||
|
||||
# adb + emulator are only guaranteed present after the install above.
|
||||
adb = resolve_tool(sdk_root, [["platform-tools", "adb" + EXE]], "adb")
|
||||
emulator = resolve_tool(sdk_root, [["emulator", "emulator" + EXE]], "emulator")
|
||||
|
||||
# 2. Create the AVD, mirroring CI.
|
||||
create_avd(avdmanager, emulator)
|
||||
|
||||
# 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)
|
||||
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. 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(
|
||||
cmd(str(gradlew), ":app:connectedDebugAndroidTest", "--stacktrace"),
|
||||
cwd=str(repo_root), check=False,
|
||||
).returncode
|
||||
except Exception as exc: # noqa: BLE001 - top-level guard so teardown always runs
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
test_exit = 1
|
||||
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)
|
||||
return test_exit
|
||||
print("api37 E2E passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,62 @@
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
|
||||
# `local_instrumented.py` — reliable local instrumented/E2E runs
|
||||
|
||||
A helper for running LibreMail's instrumented / E2E tests **locally** without Gradle
|
||||
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
|
||||
# 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):
|
||||
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 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
|
||||
emulator down and verifies no orphaned `qemu` process is left behind (exit **3** if one is).
|
||||
|
||||
## Why (short version)
|
||||
|
||||
- **GMD is broken locally on this box.** `apiXXDebugAndroidTest` fails in GMD's snapshot
|
||||
step — `AvdSnapshotHandler$EmulatorSnapshotCannotCreatedException: Snapshot creation
|
||||
timed out` (AEHD 2.2 can't save/load the snapshot). The emulator itself boots fine; only
|
||||
GMD's snapshot machinery is broken. CI is unaffected (it uses `connectedDebugAndroidTest`,
|
||||
not GMD). See issue #269.
|
||||
- **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 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).
|
||||
|
||||
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
|
||||
|
||||
`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())
|
||||
@@ -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.
|
||||
@@ -1,22 +1,32 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
name: Auto-update PR branches
|
||||
|
||||
# When main advances, rebase any auto-merge-armed PR that has fallen behind, so the
|
||||
# "require branches up to date" branch rule doesn't need manual branch updates. Only PRs
|
||||
# with GitHub auto-merge enabled are touched (PR_FILTER: auto_merge) — held/draft PRs are
|
||||
# left alone.
|
||||
# When main advances, rebase every open PR that has fallen behind, so the "require
|
||||
# branches up to date" branch rule doesn't need manual branch updates. All open PRs are
|
||||
# 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:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, reopened, ready_for_review]
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -28,13 +38,16 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
autoupdate:
|
||||
name: Auto-update armed PRs
|
||||
name: Auto-update open PRs
|
||||
runs-on: ubuntu-latest
|
||||
environment: CI_CD
|
||||
steps:
|
||||
- name: Update behind PRs that have auto-merge enabled
|
||||
- name: Update all behind PRs
|
||||
uses: chinthakagodawita/autoupdate@0707656cd062a3b0cf8fa9b2cda1d1404d74437e # v1.7.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.AUTOUPDATE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
PR_FILTER: "auto_merge"
|
||||
# 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
|
||||
+870
-53
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
|
||||
@@ -16,6 +16,7 @@ Use a **JDK 17–21** for the Gradle daemon. AGP 9.2 does **not** support JDK 25
|
||||
./gradlew :app:testDebugUnitTest # JVM unit tests
|
||||
./gradlew :app:lintDebug # Android lint
|
||||
./gradlew :app:ktlintCheck :app:detekt # static analysis (CI's "Static analysis" gate)
|
||||
./gradlew :app:jacocoTestReport # JVM unit-test coverage (XML+HTML under app/build/reports/jacoco/)
|
||||
# single unit test:
|
||||
./gradlew :app:testDebugUnitTest --tests "org.libremail.data.SomeClassTest"
|
||||
```
|
||||
@@ -26,10 +27,37 @@ 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` + `lintDebug` + `ktlintCheck` + `detekt` (the `/preflight` skill does
|
||||
this). `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.
|
||||
Emulator E2E is left to CI unless asked.
|
||||
`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. 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. 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
|
||||
|
||||
@@ -58,6 +86,27 @@ JVM unit tests use JUnit4 + `kotlin.test`, **Turbine** for `Flow`, **MockK** for
|
||||
**GreenMail** for a real in-process IMAP/SMTP server, and coroutines-test. `org.json` is
|
||||
pulled in as a real dependency for unit tests because `android.jar`'s version is a no-op stub.
|
||||
|
||||
## Definition of done
|
||||
|
||||
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 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
|
||||
|
||||
- Branch off `main`; branch names like `feat-…` / `fix-…`. PRs target `main` and must pass
|
||||
|
||||
+348
-2
@@ -1,4 +1,7 @@
|
||||
// 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
|
||||
|
||||
plugins {
|
||||
@@ -9,6 +12,11 @@ plugins {
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("com.google.devtools.ksp")
|
||||
id("com.google.dagger.hilt.android")
|
||||
// JaCoCo (Gradle built-in) — unit-test code-coverage reporting (issue #192). The base `jacoco`
|
||||
// plugin auto-instruments the JVM `testDebugUnitTest` task; the jacocoTestReport task below turns
|
||||
// its exec data into XML + HTML. AGP-9-safe: it does NOT apply org.jetbrains.kotlin.android (which
|
||||
// ClassCastExceptions against AGP 9's built-in-Kotlin DSL — see CLAUDE.md) and touches no variant DSL.
|
||||
jacoco
|
||||
// Lint/format — resolved from the Gradle Plugin Portal (not the buildscript classpath).
|
||||
alias(libs.plugins.ktlint)
|
||||
alias(libs.plugins.detekt)
|
||||
@@ -58,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
|
||||
@@ -88,6 +102,14 @@ android {
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
// Ship ARM only — arm64-v8a (64-bit) + armeabi-v7a (32-bit); x86/x86_64 are
|
||||
// intentionally dropped. Scoped to this build type ONLY — do NOT move to defaultConfig
|
||||
// or the debug type: CI's E2E matrix runs the debug build on x86_64 emulators
|
||||
// (.github/workflows/ci.yml) and needs the x86_64 native libs (incl. libsqlcipher.so).
|
||||
// See docs/play-compliance.md and docs/fdroid-compliance.md for the coverage tradeoff.
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,14 +148,35 @@ 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.
|
||||
// `./gradlew api29DebugAndroidTest`. Gradle provisions/boots/tears down the emulators and
|
||||
// downloads the system images on first use. Keep this list in lockstep with the CI matrix in
|
||||
// .github/workflows/ci.yml; when a new Android ships, add it and drop the oldest level that
|
||||
// has fallen outside ~7 years. API 37 (preview) is exercised on the dev emulator until a
|
||||
// stable managed-device image is published, so it is intentionally not listed here.
|
||||
// has fallen outside ~7 years.
|
||||
//
|
||||
// API 37 (preview) is intentionally NOT listed here (re-confirmed 2026-07, see PR that added
|
||||
// API 35/37 to preflight): its only published system image is the nonstandard
|
||||
// "android-37.0" / google_apis_ps16k pairing that the e2e-preview job in
|
||||
// .github/workflows/ci.yml installs directly via sdkmanager. ManagedVirtualDevice only knows
|
||||
// how to build an "android-<apiLevel:Int>" package id (e.g. `apiLevel = 37` → "android-37")
|
||||
// or an "android-<apiPreview:codename>" one — neither produces "android-37.0" — so there is
|
||||
// no DSL path to this image today, the same root cause documented on e2e-preview for why
|
||||
// reactivecircus/android-emulator-runner can't provision it either. issue #124's perf doc
|
||||
// (docs/perf/issue-124-unified-inbox-paging.md) independently corroborates this: its API 37
|
||||
// cross-check used a physical Pixel, not an emulator/AVD. Locally, preflight covers API 37
|
||||
// by hand-provisioning it with .claude/skills/preflight/api37_e2e.py, which mirrors the
|
||||
// e2e-preview job (same image + emulator flags, except it renders on the host GPU via
|
||||
// `-gpu auto-no-window` locally instead of CI's headless `-gpu swiftshader_indirect`). Once
|
||||
// a managed-device-compatible image is published, add `api37` here (and to the CI matrix),
|
||||
// delete that script, and drop the e2e-preview job.
|
||||
managedDevices {
|
||||
localDevices {
|
||||
listOf(29, 30, 31, 32, 33, 34, 35, 36).forEach { api ->
|
||||
@@ -164,6 +207,285 @@ detekt {
|
||||
config.setFrom(rootProject.file("config/detekt/detekt.yml"))
|
||||
}
|
||||
|
||||
// Pin a modern JaCoCo (version catalog) so the coverage agent understands Kotlin 2.4.0 bytecode
|
||||
// on JDK 21.
|
||||
jacoco {
|
||||
toolVersion = libs.versions.jacoco.get()
|
||||
}
|
||||
|
||||
// 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")
|
||||
group = "verification"
|
||||
description = "Generates JaCoCo XML + HTML coverage for the debug JVM unit tests."
|
||||
|
||||
reports {
|
||||
xml.required.set(true)
|
||||
html.required.set(true)
|
||||
}
|
||||
|
||||
classDirectories.setFrom(jacocoClassDirectories)
|
||||
sourceDirectories.setFrom(jacocoSourceDirectories)
|
||||
executionData.setFrom(jacocoExecutionData)
|
||||
}
|
||||
|
||||
// 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."
|
||||
|
||||
classDirectories.setFrom(jacocoClassDirectories)
|
||||
sourceDirectories.setFrom(jacocoSourceDirectories)
|
||||
executionData.setFrom(jacocoExecutionData)
|
||||
|
||||
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 {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
@@ -224,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)
|
||||
@@ -231,4 +571,10 @@ dependencies {
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
androidTestImplementation(libs.androidx.room.testing)
|
||||
// Instrumented DatabaseProvisioner test: fakes the security/settings collaborators and spies the
|
||||
// DatabaseEncryption object to regression-guard the SQLCipher native-lib load before a keyed open.
|
||||
androidTestImplementation(libs.mockk.android)
|
||||
// TestListenableWorkerBuilder for the instrumented PruneWorker/BackfillWorker cache-lock-deferral
|
||||
// test (issue #226): builds a CoroutineWorker with its real (non-Hilt) constructor args on-device.
|
||||
androidTestImplementation(libs.androidx.work.testing)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 2,
|
||||
"identityHash": "6705bfa56d02b55f47c5c66ba10143b0",
|
||||
"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, `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": "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, '6705bfa56d02b55f47c5c66ba10143b0')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 19,
|
||||
"identityHash": "eb8510d29c6bd5fdb450ab835b6a770e",
|
||||
"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`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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, 'eb8510d29c6bd5fdb450ab835b6a770e')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
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 org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.dao.AccountDao
|
||||
import org.libremail.data.local.entity.AccountEntity
|
||||
import org.libremail.data.local.entity.ServerConfigEmbedded
|
||||
|
||||
/**
|
||||
* Real-SQLite behavior of [AccountDao] in the non-auth [AccountDatabase]: the email-ordered
|
||||
* list/observe reads, point lookup, upsert-on-conflict, and deletion.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AccountDaoTest {
|
||||
|
||||
private lateinit var db: AccountDatabase
|
||||
private lateinit var dao: AccountDao
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
|
||||
dao = db.accountDao()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
private fun account(id: String, email: String, displayName: String = "Name") = AccountEntity(
|
||||
id = id,
|
||||
email = email,
|
||||
displayName = displayName,
|
||||
authType = "PASSWORD_IMAP",
|
||||
imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"),
|
||||
smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun observeAllAndGetAllReturnAccountsOrderedByEmail() = runBlocking {
|
||||
dao.upsert(account("2", "zed@example.org"))
|
||||
dao.upsert(account("1", "ada@example.org"))
|
||||
|
||||
assertEquals(listOf("ada@example.org", "zed@example.org"), dao.observeAll().first().map { it.email })
|
||||
assertEquals(listOf("ada@example.org", "zed@example.org"), dao.getAll().map { it.email })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getByIdReturnsTheAccountOrNullAndEmbedsServerConfig() = runBlocking {
|
||||
dao.upsert(account("acct", "ada@example.org"))
|
||||
|
||||
val stored = dao.getById("acct")
|
||||
assertEquals("ada@example.org", stored?.email)
|
||||
assertEquals(993, stored?.imap?.port)
|
||||
assertEquals("smtp.example.org", stored?.smtp?.host)
|
||||
assertNull(dao.getById("absent"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upsertOnAConflictingIdUpdatesTheRowInPlace() = runBlocking {
|
||||
// Non-destructive by design (issue #309): a second upsert of the same id refreshes the row
|
||||
// rather than delete-then-reinserting it (which would cascade-delete settings/signatures).
|
||||
dao.upsert(account("acct", "ada@example.org", displayName = "Ada"))
|
||||
|
||||
dao.upsert(account("acct", "ada@example.org", displayName = "Ada Lovelace"))
|
||||
|
||||
assertEquals("Ada Lovelace", dao.getById("acct")?.displayName)
|
||||
assertEquals(1, dao.getAll().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteByIdRemovesTheAccount() = runBlocking {
|
||||
dao.upsert(account("acct", "ada@example.org"))
|
||||
|
||||
dao.deleteById("acct")
|
||||
|
||||
assertNull(dao.getById("acct"))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -221,11 +244,38 @@ class AccountDataMigratorTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun copiedAccountsGetAStableAlphabeticalInitialSortOrder() = runBlocking<Unit> {
|
||||
// Insert three accounts in non-alphabetical order so the assertion proves the initial order is
|
||||
// by email (the pre-#111 listing) rather than the copy's insertion order (issue #164). sortOrder
|
||||
// is a destination-only column the v14 cache never had, so it is assigned entirely by the copy.
|
||||
helper.createDatabase(cacheName, 14).apply {
|
||||
listOf("c" to "carol@example.org", "a" to "ada@example.org", "b" to "bob@example.org")
|
||||
.forEach { (id, email) ->
|
||||
execSQL(
|
||||
"INSERT INTO accounts (id, email, displayName, authType, imap_host, imap_port, " +
|
||||
"imap_security, smtp_host, smtp_port, smtp_security) VALUES " +
|
||||
"('$id', '$email', '$id', 'PASSWORD_IMAP', 'imap.example.org', 993, 'SSL_TLS', " +
|
||||
"'smtp.example.org', 465, 'SSL_TLS')",
|
||||
)
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile)
|
||||
|
||||
openAccountsDb().apply {
|
||||
// getAll orders by sortOrder; the copy ranked by email, so ada(0) < bob(1) < carol(2).
|
||||
assertEquals(listOf("a", "b", "c"), accountDao().getAll().map { it.id })
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migratorDdlMatchesExportedAccountDatabaseSchema() {
|
||||
val schema = JSONObject(
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open("org.libremail.data.local.AccountDatabase/1.json")
|
||||
.open(latestAccountDatabaseSchemaAsset())
|
||||
.bufferedReader().use { it.readText() },
|
||||
).getJSONObject("database")
|
||||
val entities = schema.getJSONArray("entities")
|
||||
@@ -262,4 +312,28 @@ class AccountDataMigratorTest {
|
||||
)
|
||||
assertTrue("the signatures index must be present in the exported schema", checkedIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* The highest-versioned exported AccountDatabase schema shipped as an androidTest asset — i.e. the
|
||||
* current schema. Resolved dynamically so a schema-version bump is validated automatically and no
|
||||
* one has to remember to bump a hardcoded filename here (issue #477). Fails loudly if the schema
|
||||
* assets are missing/mislocated rather than silently validating against nothing.
|
||||
*/
|
||||
private fun latestAccountDatabaseSchemaAsset(): String {
|
||||
val assets = InstrumentationRegistry.getInstrumentation().context.assets
|
||||
val latest = assets.list(ACCOUNT_DATABASE_SCHEMA_DIR).orEmpty()
|
||||
.filter { it.endsWith(".json") }
|
||||
.mapNotNull { it.removeSuffix(".json").toIntOrNull() }
|
||||
.maxOrNull()
|
||||
assertNotNull(
|
||||
"No exported AccountDatabase schema JSON under assets/$ACCOUNT_DATABASE_SCHEMA_DIR — is " +
|
||||
"schemas/ still wired as an androidTest asset srcDir in app/build.gradle.kts?",
|
||||
latest,
|
||||
)
|
||||
return "$ACCOUNT_DATABASE_SCHEMA_DIR/$latest.json"
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ACCOUNT_DATABASE_SCHEMA_DIR = "org.libremail.data.local.AccountDatabase"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,9 +39,9 @@ class AccountDatabaseTest {
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
private fun account(id: String = "acct") = AccountEntity(
|
||||
private fun account(id: String = "acct", email: String = "$id@example.org") = AccountEntity(
|
||||
id = id,
|
||||
email = "a@example.org",
|
||||
email = email,
|
||||
displayName = "A",
|
||||
authType = "PASSWORD_IMAP",
|
||||
imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"),
|
||||
@@ -69,6 +69,34 @@ class AccountDatabaseTest {
|
||||
assertNull("account_settings must cascade-delete with its account", db.accountSettingsDao().get("acct"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertAtEndAppendsAccountsInIncreasingSortOrder() = runBlocking<Unit> {
|
||||
db.accountDao().insertAtEnd(account("a"))
|
||||
db.accountDao().insertAtEnd(account("b"))
|
||||
db.accountDao().insertAtEnd(account("c"))
|
||||
|
||||
// Each new account lands at the end: current max + 1, starting from 0 (issue #164).
|
||||
assertEquals(0, db.accountDao().getById("a")?.sortOrder)
|
||||
assertEquals(1, db.accountDao().getById("b")?.sortOrder)
|
||||
assertEquals(2, db.accountDao().getById("c")?.sortOrder)
|
||||
// observeAll now orders by sortOrder, i.e. the append (insertion) order.
|
||||
assertEquals(listOf("a", "b", "c"), db.accountDao().observeAll().first().map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reorderPersistsAndBothQueriesReflectTheNewOrder() = runBlocking<Unit> {
|
||||
listOf("a", "b", "c").forEach { db.accountDao().insertAtEnd(account(it)) }
|
||||
|
||||
db.accountDao().reorder(listOf("c", "a", "b"))
|
||||
|
||||
assertEquals(listOf("c", "a", "b"), db.accountDao().observeAll().first().map { it.id })
|
||||
assertEquals(listOf("c", "a", "b"), db.accountDao().getAll().map { it.id })
|
||||
// sortOrder is renumbered to the new positions, so the order survives an app restart.
|
||||
assertEquals(0, db.accountDao().getById("c")?.sortOrder)
|
||||
assertEquals(1, db.accountDao().getById("a")?.sortOrder)
|
||||
assertEquals(2, db.accountDao().getById("b")?.sortOrder)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signaturesCascadeWithTheirAccount() = runBlocking<Unit> {
|
||||
db.accountDao().upsert(account())
|
||||
@@ -82,4 +110,40 @@ class AccountDatabaseTest {
|
||||
db.signatureDao().observeForAccount("acct").first().isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upsertOnAnExistingIdUpdatesInPlaceWithoutCascadingSettingsOrSignatures() = runBlocking<Unit> {
|
||||
// Regression for issue #309: an @Insert(REPLACE) upsert deletes-then-reinserts on an id
|
||||
// conflict, firing the ON DELETE CASCADE that wipes the account's settings + signatures.
|
||||
db.accountDao().upsert(account("acct"))
|
||||
db.accountSettingsDao().upsert(AccountSettingsEntity("acct", signature = "Keep me"))
|
||||
db.signatureDao().upsert(SignatureEntity("sig-1", "acct", "Work", "<p>Regards</p>", isDefault = true))
|
||||
|
||||
db.accountDao().upsert(account("acct").copy(displayName = "Updated"))
|
||||
|
||||
assertEquals("Updated", db.accountDao().getById("acct")?.displayName)
|
||||
assertEquals("Keep me", db.accountSettingsDao().get("acct")?.signature)
|
||||
assertEquals(1, db.signatureDao().observeForAccount("acct").first().size)
|
||||
assertEquals(1, db.accountDao().getAll().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertAtEndReAddingAnExistingAccountKeepsItsSettingsSignaturesAndPosition() = runBlocking<Unit> {
|
||||
// The production re-add path (addOutlookAccount -> insertAtEnd -> upsert). Re-adding a
|
||||
// deterministic id must not cascade-delete its settings/signatures nor move it to the end.
|
||||
db.accountDao().insertAtEnd(account("a"))
|
||||
db.accountDao().insertAtEnd(account("b"))
|
||||
db.accountSettingsDao().upsert(AccountSettingsEntity("b", signature = "Sig B", signatureEnabled = false))
|
||||
db.signatureDao().upsert(SignatureEntity("sig-b", "b", "Work", "<p>Regards</p>", isDefault = true))
|
||||
|
||||
db.accountDao().insertAtEnd(account("b").copy(displayName = "Renamed"))
|
||||
|
||||
// Updated in place, position preserved (still 1, not appended after), children intact.
|
||||
assertEquals("Renamed", db.accountDao().getById("b")?.displayName)
|
||||
assertEquals(1, db.accountDao().getById("b")?.sortOrder)
|
||||
assertEquals("Sig B", db.accountSettingsDao().get("b")?.signature)
|
||||
assertEquals(false, db.accountSettingsDao().get("b")?.signatureEnabled)
|
||||
assertEquals(1, db.signatureDao().observeForAccount("b").first().size)
|
||||
assertEquals(2, db.accountDao().getAll().size)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Replays [AccountDatabase]'s migrations against the schema JSONs exported to `app/schemas` (shipped
|
||||
* to the test APK as assets). AccountDatabase is the non-auth account store split out of the cache in
|
||||
* issue #111; like the cache database it registers no destructive fallback, so a drifted migration
|
||||
* would crash upgrading users at first open — this test makes such drift fail in CI instead. Kept
|
||||
* separate from [MigrationTest] because each database has its own version line and MigrationTestHelper
|
||||
* binds to a single [androidx.room.RoomDatabase] class.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AccountMigrationTest {
|
||||
|
||||
@get:Rule
|
||||
val helper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
AccountDatabase::class.java,
|
||||
emptyList(),
|
||||
FrameworkSQLiteOpenHelperFactory(),
|
||||
)
|
||||
|
||||
/**
|
||||
* v1 -> v2 (issue #164): `accounts.sortOrder` appears, and existing accounts are backfilled with a
|
||||
* stable initial order matching the previous alphabetical (`ORDER BY email`) listing. The rows are
|
||||
* inserted out of alphabetical order to prove the rank is by email, not insertion order.
|
||||
*/
|
||||
@Test
|
||||
fun migrate1To2_addsSortOrderBackfilledByEmailRank() {
|
||||
helper.createDatabase(TEST_DB, 1).apply {
|
||||
insertAccount("c", "carol@example.org")
|
||||
insertAccount("a", "ada@example.org")
|
||||
insertAccount("b", "bob@example.org")
|
||||
close()
|
||||
}
|
||||
|
||||
val db = helper.runMigrationsAndValidate(TEST_DB, 2, true, ACCOUNT_MIGRATION_1_2)
|
||||
|
||||
db.query("SELECT id, sortOrder FROM accounts ORDER BY sortOrder").use { c ->
|
||||
assertTrue(c.moveToFirst())
|
||||
assertEquals("ada ranks first alphabetically, so sortOrder 0", "a", c.getString(0))
|
||||
assertEquals(0, c.getInt(1))
|
||||
assertTrue(c.moveToNext())
|
||||
assertEquals("b", c.getString(0))
|
||||
assertEquals(1, c.getInt(1))
|
||||
assertTrue(c.moveToNext())
|
||||
assertEquals("c", c.getString(0))
|
||||
assertEquals(2, c.getInt(1))
|
||||
assertFalse("all three pre-upgrade accounts must survive, and nothing else", c.moveToNext())
|
||||
}
|
||||
db.close()
|
||||
}
|
||||
|
||||
private fun SupportSQLiteDatabase.insertAccount(id: String, email: String) {
|
||||
execSQL(
|
||||
"INSERT INTO accounts (id, email, displayName, authType, imap_host, imap_port, imap_security, " +
|
||||
"smtp_host, smtp_port, smtp_security) VALUES ('$id', '$email', '$id', 'PASSWORD_IMAP', " +
|
||||
"'imap.example.org', 993, 'SSL_TLS', 'smtp.example.org', 465, 'SSL_TLS')",
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TEST_DB = "account-migration-test.db"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
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 org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.dao.AccountDao
|
||||
import org.libremail.data.local.dao.AccountSettingsDao
|
||||
import org.libremail.data.local.entity.AccountEntity
|
||||
import org.libremail.data.local.entity.AccountSettingsEntity
|
||||
import org.libremail.data.local.entity.ServerConfigEmbedded
|
||||
|
||||
/**
|
||||
* Real-SQLite behavior of [AccountSettingsDao] in [AccountDatabase]: the live observer (which emits
|
||||
* null before a row exists), the one-shot read, upsert-on-conflict, and the nullable retention
|
||||
* overrides round-tripping. A parent account row is inserted first because the settings table
|
||||
* foreign-keys to `accounts`.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AccountSettingsDaoTest {
|
||||
|
||||
private lateinit var db: AccountDatabase
|
||||
private lateinit var dao: AccountSettingsDao
|
||||
private lateinit var accountDao: AccountDao
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
|
||||
dao = db.accountSettingsDao()
|
||||
accountDao = db.accountDao()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
private suspend fun insertAccount(id: String = "acct") = accountDao.upsert(
|
||||
AccountEntity(
|
||||
id = id,
|
||||
email = "$id@example.org",
|
||||
displayName = "Name",
|
||||
authType = "PASSWORD_IMAP",
|
||||
imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"),
|
||||
smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun observeEmitsNullBeforeARowExistsThenTheRow() = runBlocking {
|
||||
insertAccount()
|
||||
assertNull("no settings row yet -> the observer emits null", dao.observe("acct").first())
|
||||
|
||||
dao.upsert(AccountSettingsEntity("acct", signature = "Cheers"))
|
||||
|
||||
assertEquals("Cheers", dao.observe("acct").first()?.signature)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getReturnsTheStoredRowOrNull() = runBlocking {
|
||||
insertAccount()
|
||||
dao.upsert(AccountSettingsEntity("acct", signatureEnabled = false, notificationsEnabled = false))
|
||||
|
||||
val stored = dao.get("acct")
|
||||
assertEquals(false, stored?.signatureEnabled)
|
||||
assertEquals(false, stored?.notificationsEnabled)
|
||||
assertNull(dao.get("absent"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upsertReplacesTheSettingsAndRoundTripsNullableRetentionOverrides() = runBlocking {
|
||||
insertAccount()
|
||||
dao.upsert(AccountSettingsEntity("acct", retentionCount = null, retentionMonths = null))
|
||||
assertNull(dao.get("acct")?.retentionCount)
|
||||
assertNull(dao.get("acct")?.retentionMonths)
|
||||
|
||||
dao.upsert(AccountSettingsEntity("acct", retentionCount = 500, retentionMonths = 6))
|
||||
|
||||
val stored = dao.get("acct")
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
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 org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.dao.AttachmentDao
|
||||
import org.libremail.data.local.dao.MessageDao
|
||||
import org.libremail.data.local.entity.AttachmentEntity
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
|
||||
/**
|
||||
* Real-SQLite behavior of [AttachmentDao]: the `partIndex` ordering, the inline-image filter shared
|
||||
* with [LibreMailDatabaseTest], REPLACE-on-conflict inserts, and the delete/replace transaction. A
|
||||
* parent message row is inserted first because the attachments table foreign-keys to `messages`
|
||||
* (Room enables `PRAGMA foreign_keys = ON`).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AttachmentDaoTest {
|
||||
|
||||
private lateinit var db: LibreMailDatabase
|
||||
private lateinit var dao: AttachmentDao
|
||||
private lateinit var messageDao: MessageDao
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build()
|
||||
dao = db.attachmentDao()
|
||||
messageDao = db.messageDao()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
private suspend fun insertParent(id: String) = messageDao.insertNew(
|
||||
listOf(
|
||||
MessageEntity(
|
||||
id = id,
|
||||
accountId = "acct",
|
||||
sender = "Ada",
|
||||
senderEmail = "ada@example.org",
|
||||
subject = "Hi",
|
||||
snippet = "",
|
||||
body = "",
|
||||
timestampMillis = 1_000L,
|
||||
isRead = false,
|
||||
isStarred = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun getForMessageReturnsEveryPartOrderedByPartIndex() = runBlocking {
|
||||
insertParent("m1")
|
||||
dao.insert(
|
||||
listOf(
|
||||
AttachmentEntity("m1", 2, "third.pdf", "application/pdf", 30),
|
||||
AttachmentEntity("m1", 0, "first.pdf", "application/pdf", 10),
|
||||
AttachmentEntity("m1", 1, "second.png", "image/png", 20, contentId = "cid1"),
|
||||
),
|
||||
)
|
||||
|
||||
// getForMessage keeps inline (cid) parts and orders by partIndex ascending.
|
||||
assertEquals(
|
||||
listOf("first.pdf", "second.png", "third.pdf"),
|
||||
dao.getForMessage("m1").map { it.filename },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertReplacesAPartWithTheSamePrimaryKey() = runBlocking {
|
||||
insertParent("m1")
|
||||
dao.insert(listOf(AttachmentEntity("m1", 0, "old.pdf", "application/pdf", 10)))
|
||||
|
||||
// Same (messageId, partIndex) -> REPLACE overwrites the earlier row.
|
||||
dao.insert(listOf(AttachmentEntity("m1", 0, "new.pdf", "application/pdf", 99)))
|
||||
|
||||
val parts = dao.getForMessage("m1")
|
||||
assertEquals(1, parts.size)
|
||||
assertEquals("new.pdf", parts.single().filename)
|
||||
assertEquals(99L, parts.single().sizeBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteForMessageRemovesOnlyThatMessagesParts() = runBlocking {
|
||||
insertParent("m1")
|
||||
insertParent("m2")
|
||||
dao.insert(listOf(AttachmentEntity("m1", 0, "a.pdf", "application/pdf", 1)))
|
||||
dao.insert(listOf(AttachmentEntity("m2", 0, "b.pdf", "application/pdf", 1)))
|
||||
|
||||
dao.deleteForMessage("m1")
|
||||
|
||||
assertTrue(dao.getForMessage("m1").isEmpty())
|
||||
assertEquals(listOf("b.pdf"), dao.getForMessage("m2").map { it.filename })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replaceForMessageSwapsTheWholeAttachmentSetInOneTransaction() = runBlocking {
|
||||
insertParent("m1")
|
||||
dao.insert(
|
||||
listOf(
|
||||
AttachmentEntity("m1", 0, "old-a.pdf", "application/pdf", 1),
|
||||
AttachmentEntity("m1", 1, "old-b.pdf", "application/pdf", 2),
|
||||
),
|
||||
)
|
||||
|
||||
dao.replaceForMessage(
|
||||
"m1",
|
||||
listOf(AttachmentEntity("m1", 0, "fresh.pdf", "application/pdf", 3)),
|
||||
)
|
||||
|
||||
assertEquals(listOf("fresh.pdf"), dao.observeForMessage("m1").first().map { it.filename })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.dao.BackfillProgressDao
|
||||
import org.libremail.data.local.entity.BackfillProgressEntity
|
||||
|
||||
/**
|
||||
* Real-SQLite behavior of [BackfillProgressDao] — the per-(account, folder) paging boundary the
|
||||
* full-history backfill persists so it resumes after process death: point read, upsert-on-conflict,
|
||||
* account-scoped clear, and the global reset used when the retention default changes.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class BackfillProgressDaoTest {
|
||||
|
||||
private lateinit var db: LibreMailDatabase
|
||||
private lateinit var dao: BackfillProgressDao
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build()
|
||||
dao = db.backfillProgressDao()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
@Test
|
||||
fun getReturnsTheStoredBoundaryOrNull() = runBlocking {
|
||||
dao.upsert(BackfillProgressEntity("acct", "INBOX", nextBeforeUid = 42, complete = false))
|
||||
|
||||
val stored = dao.get("acct", "INBOX")
|
||||
assertEquals(42L, stored?.nextBeforeUid)
|
||||
assertEquals(false, stored?.complete)
|
||||
assertNull(dao.get("acct", "Archive"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upsertReplacesTheBoundaryForTheSameAccountAndFolder() = runBlocking {
|
||||
dao.upsert(BackfillProgressEntity("acct", "INBOX", nextBeforeUid = 100, complete = false))
|
||||
|
||||
// A later page lowers the boundary and can mark the folder complete.
|
||||
dao.upsert(BackfillProgressEntity("acct", "INBOX", nextBeforeUid = 10, complete = true))
|
||||
|
||||
val stored = dao.get("acct", "INBOX")
|
||||
assertEquals(10L, stored?.nextBeforeUid)
|
||||
assertEquals(true, stored?.complete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteForAccountClearsOnlyThatAccountsProgress() = runBlocking {
|
||||
dao.upsert(BackfillProgressEntity("acct", "INBOX", nextBeforeUid = 1))
|
||||
dao.upsert(BackfillProgressEntity("acct", "Archive", nextBeforeUid = 2))
|
||||
dao.upsert(BackfillProgressEntity("acct2", "INBOX", nextBeforeUid = 3))
|
||||
|
||||
dao.deleteForAccount("acct")
|
||||
|
||||
assertNull(dao.get("acct", "INBOX"))
|
||||
assertNull(dao.get("acct", "Archive"))
|
||||
assertEquals(3L, dao.get("acct2", "INBOX")?.nextBeforeUid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteAllClearsEveryAccountsProgress() = runBlocking {
|
||||
dao.upsert(BackfillProgressEntity("acct", "INBOX", nextBeforeUid = 1))
|
||||
dao.upsert(BackfillProgressEntity("acct2", "INBOX", nextBeforeUid = 2))
|
||||
|
||||
dao.deleteAll()
|
||||
|
||||
assertNull(dao.get("acct", "INBOX"))
|
||||
assertNull(dao.get("acct2", "INBOX"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.coldopen.ColdOpenCacheProbe
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Issue #221: a **process-isolated** cold open of a pre-encrypted cache — the SQLCipher regression fixed
|
||||
* in 592a797. The crash only surfaced on a cold process opening an already-encrypted cache with nothing
|
||||
* to convert, where the keyed open reached `SQLiteConnection.nativeOpen` with the native `.so` unloaded
|
||||
* and threw `UnsatisfiedLinkError`. Every existing on-device test ([DatabaseEncryptionTest],
|
||||
* [DatabaseProvisionerInstrumentedTest], `DatabaseModuleInstrumentedTest`, `AccountDataMigratorTest`)
|
||||
* runs a conversion first, which loads the process-global library in-process — masking the bug exactly
|
||||
* as production did before the fix.
|
||||
*
|
||||
* Process isolation is unavoidable here: `System.loadLibrary` is process-global, so once THIS
|
||||
* (instrumentation) process mints the encrypted fixture, it can no longer observe a cold open. The
|
||||
* fixture is therefore opened in a separate `:coldopen` app process hosted by [ColdOpenCacheProbe] (a
|
||||
* debug-only [android.content.ContentProvider]); this test mints the fixture here — "a file created by a
|
||||
* prior encrypted DB instance" — and drives the cold open there via `ContentResolver.call`, which spins
|
||||
* that pristine process up on demand. The harness [ColdOpenCacheProbe.KEY_COLD_PROBE] check makes the
|
||||
* isolation self-verifying: if the library were already loaded in the harness process, this test fails
|
||||
* rather than passing a hollow assertion.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ColdOpenEncryptedCacheTest {
|
||||
|
||||
private val appContext = ApplicationProvider.getApplicationContext<Context>()
|
||||
private val dbName = "coldopen_encrypted_cache_test.db"
|
||||
private val dbFile: File get() = appContext.getDatabasePath(dbName)
|
||||
|
||||
// 64 hex chars == a 32-byte SQLCipher passphrase, matching DatabaseKeyStore's format.
|
||||
private val passphrase = "0123456789abcdef".repeat(4)
|
||||
|
||||
@Before
|
||||
@After
|
||||
fun clean() {
|
||||
appContext.deleteDatabase(dbName)
|
||||
dbFile.parentFile?.listFiles { f -> f.name.startsWith(dbName) }?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coldProcessOpensAPreEncryptedCacheWithoutUnsatisfiedLinkError() {
|
||||
// Mint a real SQLCipher-encrypted cache with one row HERE, in the instrumentation process. This
|
||||
// loads the process-global .so in THIS process — precisely why the cold open must be observed in a
|
||||
// different, pristine process.
|
||||
seedEncryptedFixture()
|
||||
assertTrue("precondition: the fixture is genuinely encrypted", DatabaseEncryption.isEncrypted(dbFile))
|
||||
|
||||
val result = callColdOpenProcess()
|
||||
assertNotNull("the :coldopen process returned no result", result)
|
||||
val probe = result?.getString(ColdOpenCacheProbe.KEY_COLD_PROBE)
|
||||
val open = result?.getString(ColdOpenCacheProbe.KEY_OPEN)
|
||||
|
||||
// The harness proves its own process is genuinely cold: a keyed open with no preceding
|
||||
// System.loadLibrary must fail at nativeOpen. Anything else means the .so was already loaded there
|
||||
// and the "cold" open would be meaningless — so fail loudly instead of passing a hollow assertion.
|
||||
assertEquals(
|
||||
"the :coldopen process was not actually cold (SQLCipher already loaded); isolation broke — open=$open",
|
||||
ColdOpenCacheProbe.PROBE_UNSATISFIED_LINK,
|
||||
probe,
|
||||
)
|
||||
|
||||
// The headline #221 guard: opening the pre-encrypted cache through the production wiring on a cold
|
||||
// process succeeds (no UnsatisfiedLinkError) and reads the seeded row back.
|
||||
assertEquals(
|
||||
"cold open of the pre-encrypted cache failed (probe=$probe)",
|
||||
ColdOpenCacheProbe.OPEN_OK,
|
||||
open,
|
||||
)
|
||||
}
|
||||
|
||||
private fun callColdOpenProcess(): Bundle? {
|
||||
val authority = appContext.packageName + ColdOpenCacheProbe.AUTHORITY_SUFFIX
|
||||
return appContext.contentResolver.call(
|
||||
Uri.parse("content://$authority"),
|
||||
ColdOpenCacheProbe.METHOD_COLD_OPEN,
|
||||
null,
|
||||
bundleOf(
|
||||
ColdOpenCacheProbe.KEY_DB_NAME to dbName,
|
||||
ColdOpenCacheProbe.KEY_PASSPHRASE to passphrase,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a plaintext cache with one row and converts it to real SQLCipher ciphertext — the steady-
|
||||
* state shape an already-encrypted install presents at the next cold start, where `ensureEncrypted`
|
||||
* has nothing left to convert (592a797's exact failure mode).
|
||||
*/
|
||||
private fun seedEncryptedFixture() {
|
||||
Room.databaseBuilder(appContext, LibreMailDatabase::class.java, dbName).build().apply {
|
||||
runBlocking { messageDao().insertNew(listOf(message(ColdOpenCacheProbe.EXPECTED_ROW_ID))) }
|
||||
close()
|
||||
}
|
||||
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
|
||||
}
|
||||
|
||||
private fun message(id: String) = MessageEntity(
|
||||
id = id,
|
||||
accountId = "acct",
|
||||
sender = "Ada",
|
||||
senderEmail = "ada@example.org",
|
||||
subject = "Hi",
|
||||
snippet = "",
|
||||
body = "",
|
||||
timestampMillis = 1_000L,
|
||||
isRead = false,
|
||||
isStarred = false,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.dao.CredentialDao
|
||||
import org.libremail.data.local.entity.CredentialEntity
|
||||
|
||||
/**
|
||||
* Real-SQLite behavior of [CredentialDao] in [AccountDatabase]: the point read, upsert-on-conflict
|
||||
* (a rotated secret overwrites the old one), and deletion. The credentials table is not foreign-keyed
|
||||
* to `accounts`, so these rows stand alone.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CredentialDaoTest {
|
||||
|
||||
private lateinit var db: AccountDatabase
|
||||
private lateinit var dao: CredentialDao
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
|
||||
dao = db.credentialDao()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
@Test
|
||||
fun getByIdReturnsTheSecretOrNull() = runBlocking {
|
||||
dao.upsert(CredentialEntity("acct", "sealed-secret"))
|
||||
|
||||
assertEquals("sealed-secret", dao.getById("acct")?.encryptedSecret)
|
||||
assertNull(dao.getById("absent"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upsertReplacesTheSecretForTheSameAccount() = runBlocking {
|
||||
dao.upsert(CredentialEntity("acct", "old"))
|
||||
|
||||
dao.upsert(CredentialEntity("acct", "rotated"))
|
||||
|
||||
assertEquals("rotated", dao.getById("acct")?.encryptedSecret)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteByIdRemovesTheCredential() = runBlocking {
|
||||
dao.upsert(CredentialEntity("acct", "sealed-secret"))
|
||||
|
||||
dao.deleteById("acct")
|
||||
|
||||
assertNull(dao.getById("acct"))
|
||||
}
|
||||
}
|
||||
+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,8 +5,8 @@ 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
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -16,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
|
||||
|
||||
/**
|
||||
@@ -53,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()
|
||||
}
|
||||
|
||||
@@ -61,11 +63,152 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isEncryptedIsFalseForMissingEmptyAndPlaintextFiles() = runBlocking<Unit> {
|
||||
val missing = File(dbFile.parentFile, "$dbName.missing")
|
||||
assertFalse("a non-existent file is not encrypted", DatabaseEncryption.isEncrypted(missing))
|
||||
|
||||
val empty = File(dbFile.parentFile, "$dbName.empty")
|
||||
empty.delete()
|
||||
empty.createNewFile()
|
||||
assertFalse("a zero-length file is not encrypted", DatabaseEncryption.isEncrypted(empty))
|
||||
|
||||
openPlaintext().apply {
|
||||
messageDao().insertNew(listOf(message("acct:1")))
|
||||
close()
|
||||
}
|
||||
assertFalse("a plaintext SQLite file is not encrypted", DatabaseEncryption.isEncrypted(dbFile))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ensureEncryptedNoOpsOnMissingEmptyOrAlreadyEncryptedFiles() = runBlocking<Unit> {
|
||||
// Missing / empty: nothing to convert (the factory creates a fresh DB encrypted).
|
||||
val empty = File(dbFile.parentFile, "$dbName.empty")
|
||||
empty.createNewFile()
|
||||
DatabaseEncryption.ensureEncrypted(File(dbFile.parentFile, "$dbName.missing"), passphrase)
|
||||
DatabaseEncryption.ensureEncrypted(empty, passphrase)
|
||||
|
||||
openPlaintext().apply {
|
||||
messageDao().insertNew(listOf(message("acct:1")))
|
||||
close()
|
||||
}
|
||||
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
|
||||
assertTrue(DatabaseEncryption.isEncrypted(dbFile))
|
||||
|
||||
// A second call on an already-encrypted file is an idempotent no-op; the data stays readable.
|
||||
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
|
||||
assertTrue("the file stays encrypted", DatabaseEncryption.isEncrypted(dbFile))
|
||||
openEncrypted().apply {
|
||||
assertEquals("acct:1", messageDao().getById("acct:1")?.id)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ensurePlaintextNoOpsOnAnAlreadyPlaintextFile() = runBlocking<Unit> {
|
||||
openPlaintext().apply {
|
||||
messageDao().insertNew(listOf(message("acct:1")))
|
||||
close()
|
||||
}
|
||||
|
||||
// Already plaintext: decrypt must be a no-op (and must not corrupt the file).
|
||||
DatabaseEncryption.ensurePlaintext(dbFile, passphrase)
|
||||
|
||||
assertFalse(DatabaseEncryption.isEncrypted(dbFile))
|
||||
openPlaintext().apply {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun schemaVersionIsCarriedOntoTheEncryptedFile() = runBlocking<Unit> {
|
||||
// A first Room open stamps PRAGMA user_version to the schema version; the conversion must carry
|
||||
// it across (sqlcipher_export copies tables but not that pragma), or Room would attempt a bogus
|
||||
// migration on the re-keyed file.
|
||||
openPlaintext().apply {
|
||||
messageDao().insertNew(listOf(message("acct:1")))
|
||||
close()
|
||||
}
|
||||
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
|
||||
|
||||
val encrypted = SQLiteDatabase.openOrCreateDatabase(
|
||||
dbFile.absolutePath,
|
||||
passphrase.toByteArray(Charsets.US_ASCII),
|
||||
null,
|
||||
null,
|
||||
)
|
||||
val version = try {
|
||||
encrypted.version
|
||||
} finally {
|
||||
encrypted.close()
|
||||
}
|
||||
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 =
|
||||
Room.databaseBuilder(context, LibreMailDatabase::class.java, dbName).build()
|
||||
|
||||
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.room.Room
|
||||
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 io.mockk.unmockkObject
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
|
||||
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.local.entity.MessageEntity
|
||||
import org.libremail.data.security.DatabaseKeyCipher
|
||||
import org.libremail.data.security.DatabaseKeyStore
|
||||
import org.libremail.data.security.KeystoreCrypto
|
||||
import org.libremail.data.security.PassphraseSession
|
||||
import org.libremail.data.security.SealState
|
||||
import org.libremail.data.settings.AppSettings
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* On-device behavior of [DatabaseProvisioner.prepareCache] against REAL SQLCipher (the JVM
|
||||
* `DatabaseProvisionerTest` mocks [DatabaseEncryption], so it can't exercise a real keyed open). The
|
||||
* security/settings collaborators are faked; [DatabaseEncryption] is a spy so its real conversions run
|
||||
* while the native-lib load can still be verified.
|
||||
*
|
||||
* The headline is the steady-state regression guard for the crash fixed in 592a797: with the cache
|
||||
* already encrypted (nothing to convert) `ensureEncrypted` no-ops, so the provisioner itself must load
|
||||
* SQLCipher's native library before the keyed open — otherwise Room's `nativeOpen` throws
|
||||
* `UnsatisfiedLinkError` on every cold start. The `verify(exactly = 1) { ensureNativeLibraryLoaded() }`
|
||||
* fails if that explicit load is ever removed.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DatabaseProvisionerInstrumentedTest {
|
||||
|
||||
private val appContext = ApplicationProvider.getApplicationContext<Context>()
|
||||
private val dbName = "provisioner_instrumented_test.db"
|
||||
private val dbFile: File get() = appContext.getDatabasePath(dbName)
|
||||
|
||||
// 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>()
|
||||
|
||||
// A real ContextWrapper, NOT a mockk<Context>: mocking android.content.Context makes MockK walk the
|
||||
// whole framework class with kotlin-reflect (isKotlinInline), which trips an ART parameter-annotation
|
||||
// length mismatch and throws ArrayIndexOutOfBoundsException on API 31/32 (it passes on API 29). The
|
||||
// wrapper routes the provisioner's cache lookup to the test DB and delegates everything else.
|
||||
private val context: Context = object : ContextWrapper(appContext) {
|
||||
override fun getDatabasePath(name: String): File =
|
||||
if (name == DatabaseFiles.NAME) dbFile else super.getDatabasePath(name)
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
clean()
|
||||
coEvery { keyStore.isClearPending() } returns false
|
||||
coEvery { keyStore.resolvePassphrase(any()) } returns passphrase
|
||||
coEvery { keyStore.hasAuthSealedPassphrase() } returns false
|
||||
coEvery { keyStore.sealWithMaster() } just Runs
|
||||
coEvery { migrator.migrateIfNeeded() } just Runs
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkAll()
|
||||
clean()
|
||||
}
|
||||
|
||||
private fun clean() {
|
||||
appContext.deleteDatabase(dbName)
|
||||
dbFile.parentFile?.listFiles { f -> f.name.startsWith(dbName) }?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
private fun provisioner() = DatabaseProvisioner(context, keyStore, settingsRepository, migrator, Dispatchers.IO)
|
||||
|
||||
private fun seedPlaintextRow() {
|
||||
Room.databaseBuilder(appContext, LibreMailDatabase::class.java, dbName).build().apply {
|
||||
runBlocking { messageDao().insertNew(listOf(message("acct:1"))) }
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun openEncrypted(): LibreMailDatabase =
|
||||
Room.databaseBuilder(appContext, LibreMailDatabase::class.java, dbName)
|
||||
.openHelperFactory(SupportOpenHelperFactory(passphrase.toByteArray(Charsets.US_ASCII), null, false))
|
||||
.build()
|
||||
|
||||
private fun openPlaintext(): LibreMailDatabase =
|
||||
Room.databaseBuilder(appContext, LibreMailDatabase::class.java, dbName).build()
|
||||
|
||||
private fun message(id: String) = MessageEntity(
|
||||
id = id,
|
||||
accountId = "acct",
|
||||
sender = "Ada",
|
||||
senderEmail = "ada@example.org",
|
||||
subject = "Hi",
|
||||
snippet = "",
|
||||
body = "",
|
||||
timestampMillis = 1_000L,
|
||||
isRead = false,
|
||||
isStarred = false,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun steadyStateEncryptedStartLoadsTheNativeLibAndOpensKeyedWithoutCrashing() = runBlocking<Unit> {
|
||||
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = false))
|
||||
// Build a genuinely-encrypted, steady-state cache. Encrypt BEFORE spying so the conversion is a
|
||||
// real one; by the time prepareCache runs, ensureEncrypted has nothing left to do.
|
||||
seedPlaintextRow()
|
||||
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
|
||||
assertTrue("precondition: the cache is already encrypted", DatabaseEncryption.isEncrypted(dbFile))
|
||||
|
||||
mockkObject(DatabaseEncryption) // spy: real implementations still run
|
||||
val mode = provisioner().prepareCache()
|
||||
|
||||
assertEquals(CacheOpenMode.Encrypted(passphrase), mode)
|
||||
// Regression guard (592a797): a steady-state encrypted start converts nothing, so the provisioner
|
||||
// MUST load the native library itself before the keyed open below. Fails if that load is removed.
|
||||
verify(exactly = 1) { DatabaseEncryption.ensureNativeLibraryLoaded() }
|
||||
unmockkObject(DatabaseEncryption)
|
||||
|
||||
// The keyed open the provisioner reported must actually succeed on real SQLCipher (no crash).
|
||||
openEncrypted().apply {
|
||||
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))
|
||||
seedPlaintextRow()
|
||||
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
|
||||
assertTrue("precondition: the cache starts encrypted", DatabaseEncryption.isEncrypted(dbFile))
|
||||
|
||||
val mode = provisioner().prepareCache()
|
||||
|
||||
assertEquals(CacheOpenMode.Plaintext, mode)
|
||||
assertFalse("the cache must be decrypted so the unkeyed open works", DatabaseEncryption.isEncrypted(dbFile))
|
||||
openPlaintext().apply {
|
||||
assertEquals("acct:1", messageDao().getById("acct:1")?.id)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue #479, on real collaborators end to end: the transitional window — encryptCache already
|
||||
* OFF, the cache still SQLCipher-encrypted on disk, the passphrase still auth-sealed — must not
|
||||
* merely decrypt the file; it must also RELEASE the orphaned auth seal by resealing under the
|
||||
* non-auth master key. Otherwise the app stays in the window indefinitely, where losing the
|
||||
* auth-bound key (device-lock removal / re-enrollment) forces a needless cache wipe.
|
||||
*
|
||||
* Uses a REAL [DatabaseKeyStore] over a fresh test DataStore plus real Keystore crypto: the
|
||||
* sealed-auth blob only needs to EXIST (sealState reads presence), and the passphrase itself
|
||||
* comes from the unlocked [PassphraseSession] — exactly the state after a real authentication.
|
||||
*/
|
||||
@Test
|
||||
fun encryptionTurnedOffReleasesTheOrphanedAuthSealAfterDecrypting() = runBlocking<Unit> {
|
||||
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = false, appLock = true))
|
||||
seedPlaintextRow()
|
||||
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
|
||||
assertTrue("precondition: the cache starts encrypted", DatabaseEncryption.isEncrypted(dbFile))
|
||||
|
||||
val session = PassphraseSession().apply { unlock(passphrase) }
|
||||
val realKeyStore = DatabaseKeyStore(appContext, KeystoreCrypto(), DatabaseKeyCipher(), session)
|
||||
realKeyStore.dataStore = PreferenceDataStoreFactory.create(
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||
) { File(appContext.cacheDir, "provisioner_seal_test_${System.nanoTime()}.preferences_pb") }
|
||||
realKeyStore.dataStore.edit { it[stringPreferencesKey("sealed_db_key_auth")] = "sealed-by-auth-key" }
|
||||
assertTrue("precondition: the passphrase is auth-sealed", realKeyStore.hasAuthSealedPassphrase())
|
||||
|
||||
val provisioner = DatabaseProvisioner(context, realKeyStore, settingsRepository, migrator, Dispatchers.IO)
|
||||
val mode = provisioner.prepareCache()
|
||||
|
||||
assertEquals(CacheOpenMode.Plaintext, mode)
|
||||
assertFalse("the cache was decrypted", DatabaseEncryption.isEncrypted(dbFile))
|
||||
// The headline: no orphaned auth seal survives the conversion — it was resealed under the
|
||||
// master key, so the passphrase stays recoverable WITHOUT authentication...
|
||||
assertFalse("the auth seal must be released", realKeyStore.hasAuthSealedPassphrase())
|
||||
assertEquals(SealState.MASTER, realKeyStore.sealState())
|
||||
// ...and a later encryptCache re-enable reuses the very same passphrase (real Keystore round-trip).
|
||||
assertEquals(passphrase, realKeyStore.passphrase())
|
||||
openPlaintext().apply {
|
||||
assertEquals("acct:1", messageDao().getById("acct:1")?.id)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plaintextStartLeavesThePlaintextCacheUntouched() = runBlocking<Unit> {
|
||||
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = false, appLock = false))
|
||||
seedPlaintextRow()
|
||||
assertFalse("precondition: the cache is plaintext", DatabaseEncryption.isEncrypted(dbFile))
|
||||
|
||||
val mode = provisioner().prepareCache()
|
||||
|
||||
assertEquals(CacheOpenMode.Plaintext, mode)
|
||||
assertFalse("a plaintext-with-encryption-off start converts nothing", DatabaseEncryption.isEncrypted(dbFile))
|
||||
openPlaintext().apply {
|
||||
assertEquals("acct:1", messageDao().getById("acct:1")?.id)
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import androidx.sqlite.db.SupportSQLiteOpenHelper
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.mockk.mockk
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Behavior of [DeferredOpenHelperFactory] (issue #93): Room touches `create` and
|
||||
* `setWriteAheadLoggingEnabled` on the injection path (possibly the main thread), so the REAL open
|
||||
* helper — and the blocking startup gate that builds it — must be deferred until the database is first
|
||||
* actually opened. These tests pin that laziness, the WAL memoization, and the "never open just to
|
||||
* close" contract using a recording fake delegate (no real database needed).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DeferredOpenHelperFactoryTest {
|
||||
|
||||
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
|
||||
private class FakeOpenHelper(private val db: SupportSQLiteDatabase) : SupportSQLiteOpenHelper {
|
||||
var walEnabled: Boolean? = null
|
||||
var closed = false
|
||||
override val databaseName: String = "fake"
|
||||
override fun setWriteAheadLoggingEnabled(enabled: Boolean) {
|
||||
walEnabled = enabled
|
||||
}
|
||||
|
||||
override val writableDatabase: SupportSQLiteDatabase get() = db
|
||||
override val readableDatabase: SupportSQLiteDatabase get() = db
|
||||
override fun close() {
|
||||
closed = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun configuration(name: String): SupportSQLiteOpenHelper.Configuration =
|
||||
SupportSQLiteOpenHelper.Configuration.builder(context)
|
||||
.name(name)
|
||||
.callback(object : SupportSQLiteOpenHelper.Callback(1) {
|
||||
override fun onCreate(db: SupportSQLiteDatabase) = Unit
|
||||
override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit
|
||||
})
|
||||
.build()
|
||||
|
||||
@Test
|
||||
fun theDelegateIsBuiltLazilyOnFirstOpenAndThenMemoized() {
|
||||
var builds = 0
|
||||
val db = mockk<SupportSQLiteDatabase>(relaxed = true)
|
||||
val fake = FakeOpenHelper(db)
|
||||
val helper = DeferredOpenHelperFactory {
|
||||
builds++
|
||||
fake
|
||||
}.create(configuration("deferred-lazy"))
|
||||
|
||||
// create() must not build the real delegate (it runs on the possibly-main injection thread).
|
||||
assertEquals("create() must not build the delegate", 0, builds)
|
||||
assertEquals("deferred-lazy", helper.databaseName)
|
||||
|
||||
// WAL can be set before the first open; it must be remembered, not force an early build.
|
||||
helper.setWriteAheadLoggingEnabled(true)
|
||||
assertEquals("setWriteAheadLoggingEnabled must not build the delegate", 0, builds)
|
||||
|
||||
// The first open builds the delegate and applies the remembered WAL setting.
|
||||
assertSame(db, helper.writableDatabase)
|
||||
assertEquals(1, builds)
|
||||
assertEquals("the remembered WAL flag is applied when the delegate is built", true, fake.walEnabled)
|
||||
|
||||
// Subsequent opens reuse the same delegate.
|
||||
assertSame(db, helper.readableDatabase)
|
||||
assertEquals("the delegate is memoized after the first open", 1, builds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closeBeforeAnyOpenNeverBuildsTheDelegate() {
|
||||
var builds = 0
|
||||
val fake = FakeOpenHelper(mockk(relaxed = true))
|
||||
val helper = DeferredOpenHelperFactory {
|
||||
builds++
|
||||
fake
|
||||
}.create(configuration("deferred-close"))
|
||||
|
||||
helper.close()
|
||||
|
||||
assertEquals("close() must not build a delegate just to close it", 0, builds)
|
||||
assertFalse("a never-built delegate is never closed", fake.closed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
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 org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.dao.DraftDao
|
||||
import org.libremail.data.local.entity.DraftEntity
|
||||
|
||||
/**
|
||||
* Real-SQLite behavior of [DraftDao] — the saved-but-unsent store the compose autosave writes:
|
||||
* newest-first observation by `updatedAt`, the live count, point/bulk reads, upsert-on-conflict, and
|
||||
* deletion.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DraftDaoTest {
|
||||
|
||||
private lateinit var db: LibreMailDatabase
|
||||
private lateinit var dao: DraftDao
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build()
|
||||
dao = db.draftDao()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
private fun draft(id: String, updatedAt: Long = 1_000L, subject: String = "Draft $id") = DraftEntity(
|
||||
id = id,
|
||||
accountId = "acct",
|
||||
toAddresses = "bob@example.org",
|
||||
ccAddresses = "",
|
||||
subject = subject,
|
||||
body = "Body",
|
||||
updatedAt = updatedAt,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun observeAllReturnsDraftsNewestFirstAndObserveCountTracksThem() = runBlocking {
|
||||
dao.upsert(draft("old", updatedAt = 100))
|
||||
dao.upsert(draft("new", updatedAt = 300))
|
||||
dao.upsert(draft("mid", updatedAt = 200))
|
||||
|
||||
assertEquals(listOf("new", "mid", "old"), dao.observeAll().first().map { it.id })
|
||||
assertEquals(3, dao.observeCount().first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getByIdAndGetAllReadStoredDrafts() = runBlocking {
|
||||
dao.upsert(draft("d1"))
|
||||
dao.upsert(draft("d2"))
|
||||
|
||||
assertEquals("Draft d1", dao.getById("d1")?.subject)
|
||||
assertNull(dao.getById("absent"))
|
||||
assertEquals(setOf("d1", "d2"), dao.getAll().map { it.id }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upsertReplacesADraftWithTheSameId() = runBlocking {
|
||||
dao.upsert(draft("d1", subject = "First"))
|
||||
|
||||
dao.upsert(draft("d1", subject = "Edited"))
|
||||
|
||||
assertEquals("Edited", dao.getById("d1")?.subject)
|
||||
assertEquals(1, dao.observeCount().first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteRemovesTheDraft() = runBlocking {
|
||||
dao.upsert(draft("d1"))
|
||||
dao.upsert(draft("d2"))
|
||||
|
||||
dao.delete("d1")
|
||||
|
||||
assertEquals(listOf("d2"), dao.getAll().map { it.id })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.dao.FolderDao
|
||||
import org.libremail.data.local.entity.FolderEntity
|
||||
|
||||
/**
|
||||
* Real-SQLite behavior of [FolderDao] not already covered by [LibreMailDatabaseTest] (which pins
|
||||
* `observeForAccount` ordering + `replaceForAccount`): the one-shot [FolderDao.getForAccountOnce]
|
||||
* read, REPLACE-on-conflict for a re-listed folder, and the account scoping of a delete.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FolderDaoTest {
|
||||
|
||||
private lateinit var db: LibreMailDatabase
|
||||
private lateinit var dao: FolderDao
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build()
|
||||
dao = db.folderDao()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
@Test
|
||||
fun getForAccountOnceReturnsFoldersOrderedBySortOrder() = runBlocking {
|
||||
dao.insertAll(
|
||||
listOf(
|
||||
FolderEntity("acct", "Archive", "Archive", "ARCHIVE", selectable = true, sortOrder = 2),
|
||||
FolderEntity("acct", "INBOX", "INBOX", "INBOX", selectable = true, sortOrder = 0),
|
||||
FolderEntity("acct", "Sent", "Sent", "SENT", selectable = true, sortOrder = 1),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("INBOX", "Sent", "Archive"), dao.getForAccountOnce("acct").map { it.fullName })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertAllReplacesAFolderWithTheSamePrimaryKey() = runBlocking {
|
||||
dao.insertAll(
|
||||
listOf(FolderEntity("acct", "INBOX", "Old label", "INBOX", selectable = true, sortOrder = 0)),
|
||||
)
|
||||
|
||||
// A re-list of INBOX (same accountId + fullName) replaces the cached row.
|
||||
dao.insertAll(
|
||||
listOf(FolderEntity("acct", "INBOX", "New label", "INBOX", selectable = true, sortOrder = 0)),
|
||||
)
|
||||
|
||||
val folders = dao.getForAccountOnce("acct")
|
||||
assertEquals(1, folders.size)
|
||||
assertEquals("New label", folders.single().displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteForAccountRemovesOnlyThatAccountsFolders() = runBlocking {
|
||||
dao.insertAll(listOf(FolderEntity("acct", "INBOX", "INBOX", "INBOX", selectable = true, sortOrder = 0)))
|
||||
dao.insertAll(listOf(FolderEntity("acct2", "INBOX", "INBOX", "INBOX", selectable = true, sortOrder = 0)))
|
||||
|
||||
dao.deleteForAccount("acct")
|
||||
|
||||
assertTrue(dao.getForAccountOnce("acct").isEmpty())
|
||||
assertEquals(listOf("INBOX"), dao.getForAccountOnce("acct2").map { it.fullName })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
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
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.dao.MessageDao
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
import org.libremail.data.local.entity.MessageSummary
|
||||
|
||||
/**
|
||||
* Real-SQLite behavior of the [MessageDao] query and mutation surface not already pinned by
|
||||
* [MessageDaoRetentionTest] (the retention/backfill boundary probes) or [MessageDaoRoutingTest] (the
|
||||
* body-less routing projection): the Paging 3 browse/search sources, the flag/body/header mutators,
|
||||
* and the scoped deletes. Exercised against a real in-memory database so the generated SQL — and its
|
||||
* `inInbox`/folder/account scoping and ordering — runs for real.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MessageDaoTest {
|
||||
|
||||
private lateinit var db: LibreMailDatabase
|
||||
private lateinit var dao: MessageDao
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build()
|
||||
dao = db.messageDao()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun message(
|
||||
id: String,
|
||||
accountId: String = "acct",
|
||||
folder: String = "INBOX",
|
||||
subject: String = "Hi",
|
||||
sender: String = "Ada",
|
||||
senderEmail: String = "ada@example.org",
|
||||
snippet: String = "",
|
||||
body: String = "",
|
||||
timestampMillis: Long = 1_000L,
|
||||
isRead: Boolean = false,
|
||||
isStarred: Boolean = false,
|
||||
inInbox: Boolean = true,
|
||||
bodyFetched: Boolean = false,
|
||||
isHtml: Boolean = false,
|
||||
uid: Long = 0L,
|
||||
) = MessageEntity(
|
||||
id = id,
|
||||
accountId = accountId,
|
||||
sender = sender,
|
||||
senderEmail = senderEmail,
|
||||
subject = subject,
|
||||
snippet = snippet,
|
||||
body = body,
|
||||
isHtml = isHtml,
|
||||
timestampMillis = timestampMillis,
|
||||
isRead = isRead,
|
||||
isStarred = isStarred,
|
||||
folder = folder,
|
||||
inInbox = inInbox,
|
||||
bodyFetched = bodyFetched,
|
||||
uid = uid,
|
||||
// Mirror production's Unicode-casefold population (issue #232): Mappers.toEntity + MessageDao's
|
||||
// updateHeaderContent/updateBody write `lowercase()` copies of the searchable fields, and the
|
||||
// *SearchSummaries queries match against these `*Fold` columns — so fixtures must set them too.
|
||||
senderFold = sender.lowercase(),
|
||||
senderEmailFold = senderEmail.lowercase(),
|
||||
subjectFold = subject.lowercase(),
|
||||
snippetFold = snippet.lowercase(),
|
||||
)
|
||||
|
||||
/** 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 pagingUnifiedFolderSummariesReturnsSyncedRowsNewestFirstAcrossAccounts() = runBlocking {
|
||||
dao.insertNew(
|
||||
listOf(
|
||||
message("acct:INBOX:1", timestampMillis = 100),
|
||||
message("acct:INBOX:2", timestampMillis = 300),
|
||||
message("acct2:INBOX:3", accountId = "acct2", timestampMillis = 200),
|
||||
message("acct:INBOX:search", timestampMillis = 999, inInbox = false), // search-only excluded
|
||||
message("acct:Archive:1", folder = "Archive", timestampMillis = 500), // other folder excluded
|
||||
),
|
||||
)
|
||||
|
||||
// Newest-first by timestamp, both accounts' INBOX rows, no search-only row, no other folder.
|
||||
assertEquals(
|
||||
listOf("acct:INBOX:2", "acct2:INBOX:3", "acct:INBOX:1"),
|
||||
dao.pagingUnifiedFolderSummaries("INBOX").refreshIds(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pagingFolderSummariesIsScopedToOneAccountAndFolder() = runBlocking {
|
||||
dao.insertNew(
|
||||
listOf(
|
||||
message("acct:INBOX:1", timestampMillis = 100),
|
||||
message("acct:INBOX:2", timestampMillis = 200),
|
||||
message("acct2:INBOX:3", accountId = "acct2", timestampMillis = 300), // other account
|
||||
message("acct:Archive:1", folder = "Archive", timestampMillis = 400), // other folder
|
||||
message("acct:INBOX:s", timestampMillis = 999, inInbox = false), // search-only
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("acct:INBOX:2", "acct:INBOX:1"),
|
||||
dao.pagingFolderSummaries("acct", "INBOX").refreshIds(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pagingUnifiedFolderSearchSummariesMatchesEveryScannedColumnAndSurfacesSearchRows() = runBlocking {
|
||||
dao.insertNew(
|
||||
listOf(
|
||||
message("bySubject", subject = "Quarterly report", timestampMillis = 100),
|
||||
message("bySender", sender = "Reporter", subject = "x", timestampMillis = 200),
|
||||
message("bySenderEmail", senderEmail = "report@x.org", subject = "x", timestampMillis = 300),
|
||||
message("bySnippet", snippet = "see the report", subject = "x", timestampMillis = 400),
|
||||
message("searchHit", subject = "report", timestampMillis = 500, inInbox = false), // surfaced
|
||||
message("noMatch", subject = "unrelated", timestampMillis = 600),
|
||||
message("otherFolder", subject = "report", folder = "Archive", timestampMillis = 700),
|
||||
),
|
||||
)
|
||||
|
||||
// Unified search matches sender/senderEmail/subject/snippet, includes transient inInbox=0 hits,
|
||||
// and is folder-scoped. Newest-first.
|
||||
assertEquals(
|
||||
listOf("searchHit", "bySnippet", "bySenderEmail", "bySender", "bySubject"),
|
||||
dao.pagingUnifiedFolderSearchSummaries("INBOX", "%report%").refreshIds(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pagingFolderSearchSummariesIsAccountScopedAndSurfacesSearchRows() = runBlocking {
|
||||
dao.insertNew(
|
||||
listOf(
|
||||
message("mine", subject = "the report", timestampMillis = 100),
|
||||
message("mineSearch", subject = "report", timestampMillis = 200, inInbox = false),
|
||||
message("theirs", subject = "report", accountId = "acct2", timestampMillis = 300),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("mineSearch", "mine"),
|
||||
dao.pagingFolderSearchSummaries("acct", "INBOX", "%report%").refreshIds(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun browsePagingBreaksTimestampTiesByAscendingIdForATotalPageOrder() = runBlocking {
|
||||
// Bulk mail can share a timestamp (issue #311): without a unique tiebreaker, rows tied at a
|
||||
// LIMIT/OFFSET page boundary can duplicate or skip. Insertion order is scrambled so the ORDER BY
|
||||
// — not the storage order — must produce the result.
|
||||
dao.insertNew(
|
||||
listOf(
|
||||
message("tie-c", timestampMillis = 1_000),
|
||||
message("tie-a", timestampMillis = 1_000),
|
||||
message("tie-b", timestampMillis = 1_000),
|
||||
message("newer", timestampMillis = 2_000),
|
||||
),
|
||||
)
|
||||
|
||||
// Newest timestamp first, then ties broken by ascending id — a deterministic total order.
|
||||
val expected = listOf("newer", "tie-a", "tie-b", "tie-c")
|
||||
assertEquals(expected, dao.pagingUnifiedFolderSummaries("INBOX").refreshIds())
|
||||
assertEquals(expected, dao.pagingFolderSummaries("acct", "INBOX").refreshIds())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchPagingBreaksTimestampTiesByAscendingIdForATotalPageOrder() = runBlocking {
|
||||
dao.insertNew(
|
||||
listOf(
|
||||
message("hit-c", subject = "report", timestampMillis = 1_000),
|
||||
message("hit-a", subject = "report", timestampMillis = 1_000),
|
||||
message("hit-b", subject = "report", timestampMillis = 1_000),
|
||||
),
|
||||
)
|
||||
|
||||
val expected = listOf("hit-a", "hit-b", "hit-c")
|
||||
assertEquals(expected, dao.pagingUnifiedFolderSearchSummaries("INBOX", "%report%").refreshIds())
|
||||
assertEquals(expected, dao.pagingFolderSearchSummaries("acct", "INBOX", "%report%").refreshIds())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getUnfetchedIdsReturnsOnlySyncedRowsMissingABody() = runBlocking {
|
||||
dao.insertNew(
|
||||
listOf(
|
||||
message("unfetched", bodyFetched = false),
|
||||
message("fetched", bodyFetched = true),
|
||||
message("searchUnfetched", bodyFetched = false, inInbox = false), // not synced
|
||||
message("otherFolder", folder = "Archive", bodyFetched = false), // different folder
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("unfetched"), dao.getUnfetchedIds("acct", "INBOX"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertNewIgnoresConflictsAndLeavesExistingRowsIntact() = runBlocking {
|
||||
dao.insertNew(listOf(message("acct:1", subject = "Original", isRead = true)))
|
||||
|
||||
// A re-insert of the same id (e.g. the next sync re-listing it) must NOT clobber the cached row.
|
||||
dao.insertNew(listOf(message("acct:1", subject = "Replaced", isRead = false)))
|
||||
|
||||
val row = dao.getById("acct:1")
|
||||
assertEquals("Original", row?.subject)
|
||||
assertEquals(true, row?.isRead)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun existingIdsReturnsOnlyThoseAlreadyStored() = runBlocking {
|
||||
dao.insertNew(listOf(message("acct:1"), message("acct:2")))
|
||||
|
||||
assertEquals(
|
||||
setOf("acct:1", "acct:2"),
|
||||
dao.existingIds(listOf("acct:1", "acct:2", "acct:absent")).toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateHeaderContentRefreshesDisplayFieldsAndUidOnly() = runBlocking {
|
||||
dao.insertNew(
|
||||
listOf(
|
||||
message("acct:1", isRead = true, isStarred = true, inInbox = true, body = "cached", uid = 0),
|
||||
),
|
||||
)
|
||||
|
||||
dao.updateHeaderContent(
|
||||
id = "acct:1",
|
||||
sender = "Charles",
|
||||
senderEmail = "charles@example.org",
|
||||
subject = "Refreshed",
|
||||
timestampMillis = 5_000L,
|
||||
uid = 42L,
|
||||
)
|
||||
|
||||
val row = requireNotNull(dao.getById("acct:1"))
|
||||
assertEquals("Charles", row.sender)
|
||||
assertEquals("charles@example.org", row.senderEmail)
|
||||
assertEquals("Refreshed", row.subject)
|
||||
assertEquals(5_000L, row.timestampMillis)
|
||||
assertEquals(42L, row.uid)
|
||||
// Local flags, the cached body, and inbox membership are deliberately left untouched.
|
||||
assertEquals(true, row.isRead)
|
||||
assertEquals(true, row.isStarred)
|
||||
assertEquals("cached", row.body)
|
||||
assertEquals(true, row.inInbox)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateHeaderContentsRefreshesEveryRowInTheBatchAndLeavesFlagsAndBodiesUntouched() = runBlocking {
|
||||
dao.insertNew(
|
||||
listOf(
|
||||
message("acct:1", isRead = true, isStarred = false, body = "cached-1", uid = 0),
|
||||
message("acct:2", isRead = false, isStarred = true, body = "cached-2", uid = 0),
|
||||
),
|
||||
)
|
||||
|
||||
// The sync path (issue #310) refreshes a whole recent window at once via this single-transaction
|
||||
// batch. Only the six header fields of each passed entity are applied; the rest are ignored.
|
||||
dao.updateHeaderContents(
|
||||
listOf(
|
||||
message(
|
||||
"acct:1",
|
||||
sender = "Charles",
|
||||
senderEmail = "charles@example.org",
|
||||
subject = "One",
|
||||
timestampMillis = 5_000L,
|
||||
uid = 42L,
|
||||
),
|
||||
message(
|
||||
"acct:2",
|
||||
sender = "Grace",
|
||||
senderEmail = "grace@example.org",
|
||||
subject = "Two",
|
||||
timestampMillis = 6_000L,
|
||||
uid = 43L,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val one = requireNotNull(dao.getById("acct:1"))
|
||||
assertEquals("Charles", one.sender)
|
||||
assertEquals("charles@example.org", one.senderEmail)
|
||||
assertEquals("One", one.subject)
|
||||
assertEquals(5_000L, one.timestampMillis)
|
||||
assertEquals(42L, one.uid)
|
||||
// The casefold search columns track the refreshed headers (issue #232).
|
||||
assertEquals("charles", one.senderFold)
|
||||
assertEquals("one", one.subjectFold)
|
||||
// Flags and the cached body are deliberately left untouched.
|
||||
assertTrue(one.isRead)
|
||||
assertEquals("cached-1", one.body)
|
||||
|
||||
val two = requireNotNull(dao.getById("acct:2"))
|
||||
assertEquals("Grace", two.sender)
|
||||
assertEquals("Two", two.subject)
|
||||
assertEquals(43L, two.uid)
|
||||
assertTrue(two.isStarred)
|
||||
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(
|
||||
listOf(
|
||||
message("promote", inInbox = false),
|
||||
message("leaveAlone", inInbox = false),
|
||||
),
|
||||
)
|
||||
|
||||
dao.markSynced(listOf("promote"))
|
||||
|
||||
assertEquals(listOf("promote"), dao.getSyncedIds("acct", "INBOX"))
|
||||
assertFalse(dao.getById("leaveAlone")!!.inInbox)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateBodyStoresBodyHtmlSnippetAndMarksFetched() = runBlocking {
|
||||
dao.insertNew(listOf(message("acct:1", bodyFetched = false)))
|
||||
|
||||
dao.updateBody("acct:1", body = "<p>Hello</p>", isHtml = true, snippet = "Hello")
|
||||
|
||||
val row = requireNotNull(dao.getById("acct:1"))
|
||||
assertEquals("<p>Hello</p>", row.body)
|
||||
assertTrue(row.isHtml)
|
||||
assertEquals("Hello", row.snippet)
|
||||
assertTrue(row.bodyFetched)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setReadAndSetStarredToggleOnlyTheirFlag() = runBlocking {
|
||||
dao.insertNew(listOf(message("acct:1", isRead = false, isStarred = false)))
|
||||
|
||||
dao.setRead("acct:1", true)
|
||||
dao.setStarred("acct:1", true)
|
||||
|
||||
val row = requireNotNull(dao.getById("acct:1"))
|
||||
assertTrue(row.isRead)
|
||||
assertTrue(row.isStarred)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteByIdsRemovesExactlyTheGivenRows() = runBlocking {
|
||||
dao.insertNew(listOf(message("a"), message("b"), message("c")))
|
||||
|
||||
dao.deleteByIds(listOf("a", "c"))
|
||||
|
||||
assertNull("a is deleted", dao.getById("a"))
|
||||
assertNull("c is deleted", dao.getById("c"))
|
||||
assertEquals("b survives", "b", dao.getById("b")?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteByAccountRemovesEveryRowOfThatAccountOnly() = runBlocking {
|
||||
dao.insertNew(
|
||||
listOf(
|
||||
message("acct:1"),
|
||||
message("acct:Archive:1", folder = "Archive"),
|
||||
message("acct2:1", accountId = "acct2"),
|
||||
),
|
||||
)
|
||||
|
||||
dao.deleteByAccount("acct")
|
||||
|
||||
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
|
||||
fun deleteSyncedByAccountFolderSparesSearchRowsAndOtherFolders() = runBlocking {
|
||||
dao.insertNew(
|
||||
listOf(
|
||||
message("synced", inInbox = true),
|
||||
message("searchOnly", inInbox = false),
|
||||
message("otherFolder", folder = "Archive"),
|
||||
message("otherAccount", accountId = "acct2"),
|
||||
),
|
||||
)
|
||||
|
||||
dao.deleteSyncedByAccountFolder("acct", "INBOX")
|
||||
|
||||
assertNull("the synced INBOX row is deleted", dao.getById("synced"))
|
||||
assertTrue("a transient search-only row is spared", dao.getById("searchOnly") != null)
|
||||
assertTrue("another folder is untouched", dao.getById("otherFolder") != null)
|
||||
assertTrue("another account is untouched", dao.getById("otherAccount") != null)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.di.DatabaseModule
|
||||
import java.lang.reflect.Modifier
|
||||
|
||||
/**
|
||||
@@ -37,6 +38,82 @@ class MigrationTest {
|
||||
FrameworkSQLiteOpenHelperFactory(),
|
||||
)
|
||||
|
||||
/** v18 -> v19 (issue #232): the four casefold search columns appear, backfilled from their source. */
|
||||
@Test
|
||||
fun migrate18To19_addsAndBackfillsCasefoldSearchColumns() {
|
||||
helper.createDatabase(TEST_DB, 18).apply {
|
||||
execSQL(
|
||||
"INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " +
|
||||
"timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched, uid) VALUES " +
|
||||
"('acct:INBOX:1', 'acct', 'Ada LOVELACE', 'Ada@Example.ORG', 'HELLO There', 'World Preview', " +
|
||||
"'', 0, 1000, 0, 0, 'INBOX', 1, 1, 1)",
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
val db = helper.runMigrationsAndValidate(TEST_DB, 19, true, MIGRATION_18_19)
|
||||
|
||||
db.query(
|
||||
"SELECT senderFold, senderEmailFold, subjectFold, snippetFold FROM messages WHERE id = 'acct:INBOX:1'",
|
||||
).use { c ->
|
||||
assertTrue(c.moveToFirst())
|
||||
assertEquals("ada lovelace", c.getString(0))
|
||||
assertEquals("ada@example.org", c.getString(1))
|
||||
assertEquals("hello there", c.getString(2))
|
||||
assertEquals("world preview", c.getString(3))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
@@ -73,6 +150,170 @@ class MigrationTest {
|
||||
db.close()
|
||||
}
|
||||
|
||||
/** v7 -> v8: pre-upgrade messages are filed under INBOX and the folders table appears. */
|
||||
@Test
|
||||
fun migrate7To8_filesExistingMessagesUnderInboxAndAddsFoldersTable() {
|
||||
helper.createDatabase(TEST_DB, 7).apply {
|
||||
insertAccount()
|
||||
execSQL(
|
||||
"INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " +
|
||||
"timestampMillis, isRead, isStarred, inInbox, bodyFetched) VALUES " +
|
||||
"('acct:1', 'acct', 'Ada', 'ada@example.org', 'Hi', '', '', 0, 1000, 0, 0, 1, 1)",
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
val db = helper.runMigrationsAndValidate(TEST_DB, 8, true, MIGRATION_7_8)
|
||||
|
||||
db.query("SELECT folder FROM messages WHERE id = 'acct:1'").use { c ->
|
||||
assertTrue("the pre-upgrade message must survive", c.moveToFirst())
|
||||
assertEquals("7->8 files pre-upgrade rows under INBOX", "INBOX", c.getString(0))
|
||||
}
|
||||
// The new folders table exists and accepts a row.
|
||||
db.execSQL(
|
||||
"INSERT INTO folders (accountId, fullName, displayName, role, selectable, sortOrder) " +
|
||||
"VALUES ('acct', 'INBOX', 'INBOX', 'INBOX', 1, 0)",
|
||||
)
|
||||
assertEquals(1, db.count("folders"))
|
||||
db.close()
|
||||
}
|
||||
|
||||
/** v8 -> v9: a default per-account settings row is backfilled for every existing account. */
|
||||
@Test
|
||||
fun migrate8To9_backfillsADefaultSettingsRowPerAccount() {
|
||||
helper.createDatabase(TEST_DB, 8).apply {
|
||||
insertAccount()
|
||||
close()
|
||||
}
|
||||
|
||||
val db = helper.runMigrationsAndValidate(TEST_DB, 9, true, MIGRATION_8_9)
|
||||
|
||||
db.query("SELECT signature, signatureEnabled, notificationsEnabled FROM account_settings").use { c ->
|
||||
assertTrue("8->9 must backfill a settings row for the existing account", c.moveToFirst())
|
||||
assertEquals("", c.getString(0))
|
||||
assertEquals(1, c.getInt(1))
|
||||
assertEquals(1, c.getInt(2))
|
||||
assertFalse("exactly one settings row per account", c.moveToNext())
|
||||
}
|
||||
db.close()
|
||||
}
|
||||
|
||||
/** v9 -> v10: `outbox`/`drafts` gain an empty `bccAddresses` and queued/saved rows survive. */
|
||||
@Test
|
||||
fun migrate9To10_addsEmptyBccToOutboxAndDrafts() {
|
||||
helper.createDatabase(TEST_DB, 9).apply {
|
||||
execSQL(
|
||||
"INSERT INTO outbox (id, accountId, toAddresses, ccAddresses, subject, body, createdAt, " +
|
||||
"lastError) VALUES ('out-1', 'acct', 'bob@example.org', '', 'Queued', 'Body', 3000, NULL)",
|
||||
)
|
||||
execSQL(
|
||||
"INSERT INTO drafts (id, accountId, toAddresses, ccAddresses, subject, body, updatedAt, " +
|
||||
"attachments) VALUES ('draft-1', 'acct', 'bob@example.org', '', 'Draft', 'Text', 4000, '')",
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
val db = helper.runMigrationsAndValidate(TEST_DB, 10, true, MIGRATION_9_10)
|
||||
|
||||
db.query("SELECT bccAddresses FROM outbox WHERE id = 'out-1'").use { c ->
|
||||
assertTrue("the queued outbox row must survive", c.moveToFirst())
|
||||
assertEquals("existing outbox rows read an empty bcc", "", c.getString(0))
|
||||
}
|
||||
db.query("SELECT bccAddresses FROM drafts WHERE id = 'draft-1'").use { c ->
|
||||
assertTrue("the saved draft must survive", c.moveToFirst())
|
||||
assertEquals("existing draft rows read an empty bcc", "", c.getString(0))
|
||||
}
|
||||
db.close()
|
||||
}
|
||||
|
||||
/** v10 -> v11: nullable `bodyHtml` appears and a legacy per-account signature is carried across. */
|
||||
@Test
|
||||
fun migrate10To11_addsNullableBodyHtmlAndBackfillsTheDefaultSignature() {
|
||||
helper.createDatabase(TEST_DB, 10).apply {
|
||||
insertAccount()
|
||||
execSQL(
|
||||
"INSERT INTO account_settings (accountId, signature, signatureEnabled, notificationsEnabled) " +
|
||||
"VALUES ('acct', 'Cheers,' || char(10) || 'Ada', 1, 1)",
|
||||
)
|
||||
execSQL(
|
||||
"INSERT INTO outbox (id, accountId, toAddresses, ccAddresses, bccAddresses, subject, body, " +
|
||||
"createdAt, lastError) VALUES ('out-1', 'acct', 'bob@example.org', '', '', 'Q', 'B', 1, NULL)",
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
val db = helper.runMigrationsAndValidate(TEST_DB, 11, true, MIGRATION_10_11)
|
||||
|
||||
// The v9 signature becomes the account's default rich-text signature (newlines -> <br>).
|
||||
db.query("SELECT name, contentHtml, isDefault FROM signatures WHERE accountId = 'acct'").use { c ->
|
||||
assertTrue("10->11 must backfill the legacy per-account signature", c.moveToFirst())
|
||||
assertEquals("Signature", c.getString(0))
|
||||
assertEquals("Cheers,<br>Ada", c.getString(1))
|
||||
assertEquals(1, c.getInt(2))
|
||||
assertFalse("exactly one signature row must be backfilled", c.moveToNext())
|
||||
}
|
||||
// bodyHtml is added nullable and reads back null for a message composed before formatting.
|
||||
db.query("SELECT bodyHtml FROM outbox WHERE id = 'out-1'").use { c ->
|
||||
assertTrue(c.moveToFirst())
|
||||
assertTrue("bodyHtml must default to null (plaintext-only)", c.isNull(0))
|
||||
}
|
||||
db.close()
|
||||
}
|
||||
|
||||
/** v13 -> v14: snippets are re-derived per `isHtml`; only fetched-body rows are touched. */
|
||||
@Test
|
||||
fun migrate13To14_reDerivesSnippetsRespectingIsHtmlAndSkipsUnfetchedRows() {
|
||||
helper.createDatabase(TEST_DB, 13).apply {
|
||||
insertV13Message(
|
||||
id = "html",
|
||||
isHtml = 1,
|
||||
body = "<style>p{color:red}</style><p>Hello world</p>",
|
||||
snippet = "STALE",
|
||||
bodyFetched = 1,
|
||||
)
|
||||
insertV13Message(
|
||||
id = "plain",
|
||||
isHtml = 0,
|
||||
body = "keep <not a tag> literal",
|
||||
snippet = "STALE",
|
||||
bodyFetched = 1,
|
||||
)
|
||||
insertV13Message(id = "unfetched", isHtml = 0, body = "", snippet = "UNTOUCHED", bodyFetched = 0)
|
||||
close()
|
||||
}
|
||||
|
||||
val db = helper.runMigrationsAndValidate(TEST_DB, 14, true, MIGRATION_13_14)
|
||||
|
||||
val htmlSnippet = snippetOf(db, "html")
|
||||
assertTrue("HTML snippet keeps visible text", htmlSnippet.contains("Hello world"))
|
||||
assertFalse("HTML snippet drops leaked <style> content", htmlSnippet.contains("color"))
|
||||
assertFalse("the stale snippet is replaced", htmlSnippet.contains("STALE"))
|
||||
// A plain-text body keeps its literal angle brackets (no markup handling).
|
||||
assertTrue("plain snippet keeps literal markup", snippetOf(db, "plain").contains("<not a tag>"))
|
||||
// Rows whose body was never fetched keep their existing snippet.
|
||||
assertEquals("UNTOUCHED", snippetOf(db, "unfetched"))
|
||||
db.close()
|
||||
}
|
||||
|
||||
private fun snippetOf(db: SupportSQLiteDatabase, id: String): String =
|
||||
db.query("SELECT snippet FROM messages WHERE id = ?", arrayOf<Any>(id)).use { c ->
|
||||
assertTrue("row $id must exist", c.moveToFirst())
|
||||
c.getString(0)
|
||||
}
|
||||
|
||||
private fun SupportSQLiteDatabase.insertV13Message(
|
||||
id: String,
|
||||
isHtml: Int,
|
||||
body: String,
|
||||
snippet: String,
|
||||
bodyFetched: Int,
|
||||
) = execSQL(
|
||||
"INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " +
|
||||
"timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched, uid) " +
|
||||
"VALUES (?, 'acct', 'Ada', 'ada@example.org', 'Hi', ?, ?, ?, 1000, 0, 0, 'INBOX', 1, ?, 1)",
|
||||
arrayOf<Any>(id, snippet, body, isHtml, bodyFetched),
|
||||
)
|
||||
|
||||
/** v14 -> v15 (issue #66): `folders.hierarchyDelimiter` appears defaulting to NULL; data survives. */
|
||||
@Test
|
||||
fun migrate14To15_addsNullHierarchyDelimiterToFolders() {
|
||||
@@ -116,6 +357,23 @@ class MigrationTest {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The replay tests discover migrations reflectively, but nothing there checks that [DatabaseModule]
|
||||
* actually *registers* them. With no destructive fallback, a migration authored, schema-committed,
|
||||
* and replay-tested but omitted from `addMigrations` still crash-loops every upgrading user at first
|
||||
* database open (issue #312). Assert the builder's registered set ([DatabaseModule.ALL_MIGRATIONS])
|
||||
* is exactly the reflectively-discovered set, so such an omission fails here instead.
|
||||
*/
|
||||
@Test
|
||||
fun databaseModuleRegistersEveryDeclaredMigration() {
|
||||
assertEquals(
|
||||
"DatabaseModule.ALL_MIGRATIONS must register every Migration in Migrations.kt " +
|
||||
"(no destructive fallback, so a forgotten step crash-loops upgrades)",
|
||||
allAppMigrations.map { it.startVersion to it.endVersion },
|
||||
DatabaseModule.ALL_MIGRATIONS.sortedBy { it.startVersion }.map { it.startVersion to it.endVersion },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a database at v7 (the oldest exported schema), fills it like a used install, then
|
||||
* replays every migration one step at a time — `runMigrationsAndValidate` diffs the migrated
|
||||
@@ -368,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"
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
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 org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.dao.OutboxDao
|
||||
import org.libremail.data.local.entity.OutboxEntity
|
||||
|
||||
/**
|
||||
* Real-SQLite behavior of [OutboxDao] — the queue the send worker drains: FIFO ordering by
|
||||
* `createdAt`, point lookups, the live list/count observers, the last-error mutator, and row
|
||||
* deletion on successful send.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class OutboxDaoTest {
|
||||
|
||||
private lateinit var db: LibreMailDatabase
|
||||
private lateinit var dao: OutboxDao
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build()
|
||||
dao = db.outboxDao()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
private fun outbox(id: String, createdAt: Long = 1_000L) = OutboxEntity(
|
||||
id = id,
|
||||
accountId = "acct",
|
||||
toAddresses = "bob@example.org",
|
||||
ccAddresses = "",
|
||||
subject = "Subject $id",
|
||||
body = "Body",
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun getAllReturnsQueuedMessagesOldestFirst() = runBlocking {
|
||||
dao.insert(outbox("late", createdAt = 300))
|
||||
dao.insert(outbox("early", createdAt = 100))
|
||||
dao.insert(outbox("middle", createdAt = 200))
|
||||
|
||||
assertEquals(listOf("early", "middle", "late"), dao.getAll().map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getByIdReturnsTheRowOrNull() = runBlocking {
|
||||
dao.insert(outbox("out-1"))
|
||||
|
||||
assertEquals("Subject out-1", dao.getById("out-1")?.subject)
|
||||
assertNull(dao.getById("absent"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun observeAllAndObserveCountReflectTheQueue() = runBlocking {
|
||||
dao.insert(outbox("out-1", createdAt = 100))
|
||||
dao.insert(outbox("out-2", createdAt = 200))
|
||||
|
||||
assertEquals(listOf("out-1", "out-2"), dao.observeAll().first().map { it.id })
|
||||
assertEquals(2, dao.observeCount().first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setErrorRecordsAndThenClearsTheLastError() = runBlocking {
|
||||
dao.insert(outbox("out-1"))
|
||||
|
||||
dao.setError("out-1", "SMTP 550")
|
||||
assertEquals("SMTP 550", dao.getById("out-1")?.lastError)
|
||||
|
||||
dao.setError("out-1", null)
|
||||
assertNull("a successful retry clears the error", dao.getById("out-1")?.lastError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteRemovesTheSentRow() = runBlocking {
|
||||
dao.insert(outbox("out-1"))
|
||||
dao.insert(outbox("out-2"))
|
||||
|
||||
dao.delete("out-1")
|
||||
|
||||
assertEquals(listOf("out-2"), dao.getAll().map { it.id })
|
||||
assertEquals(1, dao.observeCount().first())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
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 org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.dao.AccountDao
|
||||
import org.libremail.data.local.dao.SignatureDao
|
||||
import org.libremail.data.local.entity.AccountEntity
|
||||
import org.libremail.data.local.entity.ServerConfigEmbedded
|
||||
import org.libremail.data.local.entity.SignatureEntity
|
||||
|
||||
/**
|
||||
* Real-SQLite behavior of [SignatureDao] in [AccountDatabase]: the default-first / name-ordered
|
||||
* observation, the default/first/count reads, and the "exactly one default per account" transaction
|
||||
* ([SignatureDao.setDefault]). A parent account row is inserted first because signatures foreign-key
|
||||
* to `accounts`.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SignatureDaoTest {
|
||||
|
||||
private lateinit var db: AccountDatabase
|
||||
private lateinit var dao: SignatureDao
|
||||
private lateinit var accountDao: AccountDao
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
|
||||
dao = db.signatureDao()
|
||||
accountDao = db.accountDao()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
private suspend fun insertAccount(id: String = "acct") = accountDao.upsert(
|
||||
AccountEntity(
|
||||
id = id,
|
||||
email = "$id@example.org",
|
||||
displayName = "Name",
|
||||
authType = "PASSWORD_IMAP",
|
||||
imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"),
|
||||
smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
|
||||
),
|
||||
)
|
||||
|
||||
private fun signature(id: String, name: String, isDefault: Boolean = false, accountId: String = "acct") =
|
||||
SignatureEntity(
|
||||
id = id,
|
||||
accountId = accountId,
|
||||
name = name,
|
||||
contentHtml = "<p>$name</p>",
|
||||
isDefault = isDefault,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun observeForAccountOrdersDefaultFirstThenByNameCaseInsensitive() = runBlocking {
|
||||
insertAccount()
|
||||
dao.upsert(signature("s-zeta", "Zeta", isDefault = true))
|
||||
dao.upsert(signature("s-alpha", "alpha"))
|
||||
dao.upsert(signature("s-beta", "Beta"))
|
||||
|
||||
// isDefault DESC puts the default first; the rest sort by name COLLATE NOCASE (alpha < Beta).
|
||||
assertEquals(
|
||||
listOf("Zeta", "alpha", "Beta"),
|
||||
dao.observeForAccount("acct").first().map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getByIdGetDefaultFirstForAccountAndCountReadTheExpectedRows() = runBlocking {
|
||||
insertAccount()
|
||||
dao.upsert(signature("s-work", "Work", isDefault = true))
|
||||
dao.upsert(signature("s-personal", "aPersonal"))
|
||||
|
||||
assertEquals("Work", dao.getById("s-work")?.name)
|
||||
assertNull(dao.getById("absent"))
|
||||
assertEquals("Work", dao.getDefault("acct")?.name)
|
||||
// firstForAccount ignores isDefault and takes the name-first row (aPersonal < Work).
|
||||
assertEquals("aPersonal", dao.firstForAccount("acct")?.name)
|
||||
assertEquals(2, dao.countForAccount("acct"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getDefaultIsNullWhenNoSignatureIsMarkedDefault() = runBlocking {
|
||||
insertAccount()
|
||||
dao.upsert(signature("s-1", "One"))
|
||||
|
||||
assertNull(dao.getDefault("acct"))
|
||||
assertEquals(0, dao.countForAccount("absent"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upsertReplacesASignatureAndDeleteRemovesIt() = runBlocking {
|
||||
insertAccount()
|
||||
dao.upsert(signature("s-1", "Original"))
|
||||
|
||||
dao.upsert(signature("s-1", "Edited"))
|
||||
assertEquals("Edited", dao.getById("s-1")?.name)
|
||||
|
||||
dao.delete("s-1")
|
||||
assertNull(dao.getById("s-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearDefaultAndMarkDefaultToggleTheFlag() = runBlocking {
|
||||
insertAccount()
|
||||
dao.upsert(signature("s-1", "One", isDefault = true))
|
||||
|
||||
dao.clearDefault("acct")
|
||||
assertNull("clearDefault drops the account's default", dao.getDefault("acct"))
|
||||
|
||||
dao.markDefault("s-1")
|
||||
assertEquals("s-1", dao.getDefault("acct")?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setDefaultMakesExactlyOneSignatureTheAccountsDefault() = runBlocking {
|
||||
insertAccount()
|
||||
dao.upsert(signature("s-1", "One", isDefault = true))
|
||||
dao.upsert(signature("s-2", "Two"))
|
||||
|
||||
dao.setDefault("acct", "s-2")
|
||||
|
||||
// The transaction clears every other default first, so only s-2 remains default.
|
||||
assertEquals("s-2", dao.getDefault("acct")?.id)
|
||||
assertEquals(false, dao.getById("s-1")?.isDefault)
|
||||
assertEquals(true, dao.getById("s-2")?.isDefault)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setDefaultIsScopedToTheAccount() = runBlocking {
|
||||
insertAccount("acct")
|
||||
insertAccount("acct2")
|
||||
dao.upsert(signature("a1", "A1", isDefault = true, accountId = "acct"))
|
||||
dao.upsert(signature("b1", "B1", isDefault = true, accountId = "acct2"))
|
||||
|
||||
dao.setDefault("acct", "a1")
|
||||
|
||||
// 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"))
|
||||
}
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
// 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.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,
|
||||
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
|
||||
}
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.sync
|
||||
|
||||
import android.content.Context
|
||||
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.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.security.DatabaseKeyStore
|
||||
import org.libremail.data.security.EncryptedCacheGuard
|
||||
import org.libremail.data.security.PassphraseSession
|
||||
import org.libremail.data.security.SealState
|
||||
import org.libremail.data.settings.AppSettings
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* On-device proof that [PruneWorker] and [BackfillWorker] defer (`Result.retry()`) while the encrypted
|
||||
* cache is locked, driving them with the REAL [EncryptedCacheGuard] rather than the mocked guard the JVM
|
||||
* `PruneWorkerTest`/`BackfillWorkerTest` use (issue #225). [EncryptedCacheGuard] depends only on
|
||||
* [SettingsRepository], [DatabaseKeyStore] (its seal state — issue #479), a raw header read of the
|
||||
* cache file, and [PassphraseSession] — never the Keystore crypto or `BiometricPrompt` — so the locked
|
||||
* state is reproduced here with no device auth: fixed settings/seal collaborators (mocked the same way
|
||||
* `DatabaseProvisionerInstrumentedTest` fakes its security/settings collaborators) plus a real,
|
||||
* never-unlocked [PassphraseSession].
|
||||
*
|
||||
* Caveat (see issue #226): the true "WorkManager cold-starts the process with no UI" can't be reproduced
|
||||
* in-process. The locked-[PassphraseSession] seam is the faithful stand-in; the auth-bound Keystore path
|
||||
* stays device-only (see `DatabaseKeyCipher`).
|
||||
*
|
||||
* "No `libremail.db` connection is opened" is proven by construction rather than by inspecting the
|
||||
* on-disk file: [PruneWorker]/[BackfillWorker] can only reach the database through the `Lazy`
|
||||
* [MailPruner]/[MailBackfiller] passed into their constructor (mirroring `DatabaseModule`'s real Hilt
|
||||
* wiring), and both gate on [EncryptedCacheGuard.isCacheLocked] BEFORE ever resolving that `Lazy`.
|
||||
* Asserting the `Lazy` is never resolved is therefore a direct, deterministic proof that no DAO method —
|
||||
* and so no Room/SQLCipher open — ran, without coupling the test to whatever else the shared
|
||||
* instrumentation process (or the real `libremail.db` file) happens to be doing.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class WorkerCacheLockDeferralInstrumentedTest {
|
||||
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
// A fresh, real instance per test (JUnit4 builds a new test-class instance per method) — never
|
||||
// unlocked here, so isUnlocked() stays false exactly like a cold process start before the user has
|
||||
// authenticated.
|
||||
private val session = PassphraseSession()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pruneWorkerDefersWithoutResolvingThePrunerWhileTheRealGuardReportsLocked() = runBlocking<Unit> {
|
||||
val cacheGuard = guardFor(appLock = true, encryptCache = true)
|
||||
assertTrue("precondition: the real guard must report locked", cacheGuard.isCacheLocked())
|
||||
|
||||
val lazyPruner = mockk<Lazy<MailPruner>>()
|
||||
val worker = TestListenableWorkerBuilder<PruneWorker>(context)
|
||||
.setWorkerFactory(pruneWorkerFactory(lazyPruner, cacheGuard))
|
||||
.build()
|
||||
|
||||
val result = withTimeout(TIMEOUT_MS) { worker.doWork() }
|
||||
|
||||
assertEquals(Result.retry(), result)
|
||||
// The invariant under test: a locked cache must never even resolve the DB-backed collaborator.
|
||||
verify(exactly = 0) { lazyPruner.get() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backfillWorkerDefersWithoutResolvingTheBackfillerWhileTheRealGuardReportsLocked() = runBlocking<Unit> {
|
||||
val cacheGuard = guardFor(appLock = true, encryptCache = true)
|
||||
assertTrue("precondition: the real guard must report locked", cacheGuard.isCacheLocked())
|
||||
|
||||
val lazyBackfiller = mockk<Lazy<MailBackfiller>>()
|
||||
val worker = TestListenableWorkerBuilder<BackfillWorker>(context)
|
||||
.setWorkerFactory(backfillWorkerFactory(lazyBackfiller, cacheGuard))
|
||||
.build()
|
||||
|
||||
val result = withTimeout(TIMEOUT_MS) { worker.doWork() }
|
||||
|
||||
assertEquals(Result.retry(), result)
|
||||
verify(exactly = 0) { lazyBackfiller.get() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Contrast case so the two tests above can't be vacuously true: the same real guard, driven by the
|
||||
* same collaborator types, reports UNLOCKED once app-lock is off. [EncryptedCacheGuard] otherwise has
|
||||
* no test of its own anywhere in the suite (only callers mocking the whole guard), so this is also
|
||||
* this class's only direct coverage of its boolean logic.
|
||||
*/
|
||||
@Test
|
||||
fun theRealGuardReportsUnlockedWhenAppLockIsOff() = runBlocking<Unit> {
|
||||
val cacheGuard = guardFor(appLock = false, encryptCache = true)
|
||||
|
||||
assertFalse(cacheGuard.isCacheLocked())
|
||||
}
|
||||
|
||||
/** Second branch of the same contrast: an authenticated session unlocks the cache even with app-lock on. */
|
||||
@Test
|
||||
fun theRealGuardReportsUnlockedOnceTheSessionIsUnlocked() = runBlocking<Unit> {
|
||||
val cacheGuard = guardFor(appLock = true, encryptCache = true)
|
||||
assertTrue("precondition: locked before authentication", cacheGuard.isCacheLocked())
|
||||
|
||||
session.unlock(FAKE_PASSPHRASE)
|
||||
|
||||
assertFalse(cacheGuard.isCacheLocked())
|
||||
}
|
||||
|
||||
// --- Issue #479: the guard answers from the seal + on-disk state, not the settings pair -------
|
||||
|
||||
/**
|
||||
* Transitional case A: encryptCache was toggled OFF but the on-disk decrypt runs only at the next
|
||||
* cold start — the cache is still auth-sealed and encrypted. The settings-derived formula said
|
||||
* "unlocked" here, so a worker proceeded into `provideDatabase` and parked forever on the
|
||||
* passphrase await (holding the provisioner mutex). The real guard must report locked so the
|
||||
* worker defers with `Result.retry()` instead.
|
||||
*/
|
||||
@Test
|
||||
fun pruneWorkerDefersDuringTheEncryptCacheOffTransitionalWindow() = runBlocking<Unit> {
|
||||
val cacheGuard = guardFor(
|
||||
appLock = true,
|
||||
encryptCache = false,
|
||||
sealState = SealState.AUTH,
|
||||
dbFile = encryptedFixtureFile(),
|
||||
)
|
||||
assertTrue(
|
||||
"precondition: auth-sealed + still-encrypted must report locked despite the off setting",
|
||||
cacheGuard.isCacheLocked(),
|
||||
)
|
||||
|
||||
val lazyPruner = mockk<Lazy<MailPruner>>()
|
||||
val worker = TestListenableWorkerBuilder<PruneWorker>(context)
|
||||
.setWorkerFactory(pruneWorkerFactory(lazyPruner, cacheGuard))
|
||||
.build()
|
||||
|
||||
val result = withTimeout(TIMEOUT_MS) { worker.doWork() }
|
||||
|
||||
assertEquals(Result.retry(), result)
|
||||
verify(exactly = 0) { lazyPruner.get() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Transitional case B: app-lock (or encryptCache) was just enabled mid-session, but the passphrase
|
||||
* is still MASTER-sealed until the next authentication reseals it — the DB opens auth-free, so
|
||||
* background sync/push/send must NOT stall on the settings pair (the old formula reported locked).
|
||||
*/
|
||||
@Test
|
||||
fun theRealGuardReportsUnlockedWhileThePassphraseIsStillMasterSealed() = runBlocking<Unit> {
|
||||
val cacheGuard = guardFor(appLock = true, encryptCache = true, sealState = SealState.MASTER)
|
||||
|
||||
assertFalse(cacheGuard.isCacheLocked())
|
||||
}
|
||||
|
||||
/**
|
||||
* A lingering (orphaned) auth seal over an already-plaintext cache — the post-conversion tail of
|
||||
* case A on installs that predate the provisioner's reseal — must not stall background work: the
|
||||
* plaintext open needs no passphrase.
|
||||
*/
|
||||
@Test
|
||||
fun theRealGuardReportsUnlockedOnceTheCacheIsPlaintextDespiteALingeringAuthSeal() = runBlocking<Unit> {
|
||||
val cacheGuard = guardFor(
|
||||
appLock = true,
|
||||
encryptCache = false,
|
||||
sealState = SealState.AUTH,
|
||||
dbFile = plaintextFixtureFile(),
|
||||
)
|
||||
|
||||
assertFalse(cacheGuard.isCacheLocked())
|
||||
}
|
||||
|
||||
/**
|
||||
* A real [EncryptedCacheGuard] over fixed (mocked) [SettingsRepository]/[DatabaseKeyStore]
|
||||
* collaborators, the real [session], and — for the [SealState.AUTH]-with-setting-off transitional
|
||||
* window — a real fixture file whose header decides the on-disk half of the answer.
|
||||
*/
|
||||
private fun guardFor(
|
||||
appLock: Boolean,
|
||||
encryptCache: Boolean,
|
||||
sealState: SealState = SealState.NONE,
|
||||
dbFile: File? = null,
|
||||
): EncryptedCacheGuard {
|
||||
val settingsRepository = mockk<SettingsRepository>()
|
||||
val settings = AppSettings(appLock = appLock, encryptCache = encryptCache)
|
||||
every { settingsRepository.settings } returns flowOf(settings)
|
||||
val keyStore = mockk<DatabaseKeyStore>()
|
||||
coEvery { keyStore.sealState() } returns sealState
|
||||
return EncryptedCacheGuard(context, settingsRepository, keyStore, session)
|
||||
.apply { if (dbFile != null) this.dbFile = dbFile }
|
||||
}
|
||||
|
||||
/** A file whose header is NOT the SQLite magic — what a SQLCipher-encrypted cache looks like. */
|
||||
private fun encryptedFixtureFile(): File = File.createTempFile("guard_encrypted", ".db", context.cacheDir).apply {
|
||||
deleteOnExit()
|
||||
writeBytes(ByteArray(48) { 0x5A })
|
||||
}
|
||||
|
||||
/** A file with a genuine plaintext-SQLite 16-byte header (padded past it). */
|
||||
private fun plaintextFixtureFile(): File = File.createTempFile("guard_plaintext", ".db", context.cacheDir).apply {
|
||||
deleteOnExit()
|
||||
writeBytes("SQLite format 3".toByteArray(Charsets.US_ASCII) + byteArrayOf(0) + ByteArray(32))
|
||||
}
|
||||
|
||||
private fun pruneWorkerFactory(lazyPruner: Lazy<MailPruner>, cacheGuard: EncryptedCacheGuard) =
|
||||
object : WorkerFactory() {
|
||||
override fun createWorker(
|
||||
appContext: Context,
|
||||
workerClassName: String,
|
||||
workerParameters: WorkerParameters,
|
||||
) = PruneWorker(appContext, workerParameters, lazyPruner, cacheGuard)
|
||||
}
|
||||
|
||||
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); never reached here — the run defers on the locked cache first.
|
||||
BackfillPacer(InteractiveImapGate()),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// Generous bound: a locked run must fail fast (no passphrase await), so this is only ever
|
||||
// approached by a real regression — a passing run returns almost immediately.
|
||||
const val TIMEOUT_MS = 5_000L
|
||||
|
||||
// 64 hex chars, matching DatabaseKeyStore's passphrase format. Never used to open a real database.
|
||||
const val FAKE_PASSPHRASE = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// 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.DatabaseKeyStore
|
||||
import org.libremail.data.security.EncryptedCacheGuard
|
||||
import org.libremail.data.security.PassphraseSession
|
||||
import org.libremail.data.security.SealState
|
||||
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, no seal) — so only the gate can defer. */
|
||||
private fun unlockedGuard(): EncryptedCacheGuard {
|
||||
val settingsRepository = mockk<SettingsRepository>()
|
||||
every { settingsRepository.settings } returns flowOf(AppSettings(appLock = false, encryptCache = true))
|
||||
// No seal exists (issue #479: the guard answers from the seal state, not the settings pair),
|
||||
// so the un-armed cache never blocks and only the fetch gate can defer the worker.
|
||||
val keyStore = mockk<DatabaseKeyStore>()
|
||||
coEvery { keyStore.sealState() } returns SealState.NONE
|
||||
return EncryptedCacheGuard(context, settingsRepository, keyStore, 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.di
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import androidx.room.Room
|
||||
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 io.mockk.verify
|
||||
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.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
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.local.LibreMailDatabase
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
import org.libremail.data.security.DatabaseKeyStore
|
||||
import org.libremail.data.settings.AppSettings
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Pins the "native lib loaded before keyed open" invariant (592a797) at the OPEN site — issue #220's
|
||||
* follow-up to #208/#210. `DatabaseProvisionerTest` (JVM, mocked) and `DatabaseProvisionerInstrumentedTest`
|
||||
* (instrumented, real SQLCipher) both pin that [DatabaseProvisioner.prepareCache] calls
|
||||
* `DatabaseEncryption.ensureNativeLibraryLoaded()` for the encrypted branch, but neither exercises
|
||||
* [DatabaseModule.provideDatabase] itself: the instrumented one opens through a hand-rolled
|
||||
* `SupportOpenHelperFactory`, bypassing the branch that actually picks between it and
|
||||
* `FrameworkSQLiteOpenHelperFactory` based on what [DatabaseProvisioner] reports. A regression that
|
||||
* breaks THAT wiring — swaps the branches, or stops gating the open on
|
||||
* [DatabaseProvisioner.prepareCache] at all — would slip through both existing guards.
|
||||
*
|
||||
* These tests call [DatabaseModule.provideDatabase] directly (a plain function on the `object`, no Hilt
|
||||
* graph needed) and drive the first real open through its own
|
||||
* [org.libremail.data.local.DeferredOpenHelperFactory] lambda, mirroring the lane-3 instrumented style:
|
||||
* MockK-mocked collaborators, a real [ContextWrapper] (never `mockk<Context>()`, which trips an ART
|
||||
* parameter-annotation mismatch on API 31/32), and real SQLCipher for the encrypted path.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DatabaseModuleInstrumentedTest {
|
||||
|
||||
private val appContext = ApplicationProvider.getApplicationContext<Context>()
|
||||
private val dbName = "database_module_instrumented_test.db"
|
||||
private val dbFile: File get() = appContext.getDatabasePath(dbName)
|
||||
|
||||
// 64 hex chars == a 32-byte SQLCipher passphrase, matching DatabaseKeyStore's format.
|
||||
private val passphrase = "0123456789abcdef".repeat(4)
|
||||
|
||||
private val keyStore = mockk<DatabaseKeyStore>()
|
||||
private val settingsRepository = mockk<SettingsRepository>()
|
||||
private val migrator = mockk<AccountDataMigrator>()
|
||||
|
||||
// provideDatabase hardcodes DatabaseFiles.NAME as the Room db name, exactly like DatabaseProvisioner
|
||||
// does internally when it resolves the cache file — so redirecting getDatabasePath for that one name
|
||||
// routes BOTH the provisioner's file checks and Room's own open through this test's private file,
|
||||
// never the app's real cache.
|
||||
private val context: Context = object : ContextWrapper(appContext) {
|
||||
override fun getDatabasePath(name: String): File =
|
||||
if (name == DatabaseFiles.NAME) dbFile 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() {
|
||||
appContext.deleteDatabase(dbName)
|
||||
dbFile.parentFile?.listFiles { f -> f.name.startsWith(dbName) }?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
private fun provisioner() = DatabaseProvisioner(context, keyStore, settingsRepository, migrator, Dispatchers.IO)
|
||||
|
||||
/**
|
||||
* Builds a genuinely plaintext cache with one row, then converts it to real SQLCipher ciphertext —
|
||||
* the steady-state shape an already-encrypted install has at the next cold start, where
|
||||
* `ensureEncrypted` has nothing left to convert (592a797's exact failure mode: the load can't ride
|
||||
* on a conversion that no-ops).
|
||||
*/
|
||||
private fun seedEncryptedCacheWithOneRow() {
|
||||
Room.databaseBuilder(appContext, LibreMailDatabase::class.java, dbName).build().apply {
|
||||
runBlocking { messageDao().insertNew(listOf(message("acct:1"))) }
|
||||
close()
|
||||
}
|
||||
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
|
||||
}
|
||||
|
||||
private fun message(id: String) = MessageEntity(
|
||||
id = id,
|
||||
accountId = "acct",
|
||||
sender = "Ada",
|
||||
senderEmail = "ada@example.org",
|
||||
subject = "Hi",
|
||||
snippet = "",
|
||||
body = "",
|
||||
timestampMillis = 1_000L,
|
||||
isRead = false,
|
||||
isStarred = false,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun encryptedBranchLoadsNativeLibraryBeforeTheKeyedOpenSucceeds() = runBlocking<Unit> {
|
||||
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = false))
|
||||
seedEncryptedCacheWithOneRow()
|
||||
assertTrue("precondition: the cache is already encrypted", DatabaseEncryption.isEncrypted(dbFile))
|
||||
|
||||
mockkObject(DatabaseEncryption) // spy: real implementations still run
|
||||
val database = DatabaseModule.provideDatabase(context, provisioner())
|
||||
try {
|
||||
// The first real open, driven by provideDatabase's own DeferredOpenHelperFactory lambda and
|
||||
// CacheOpenMode branch — NOT a hand-rolled SupportOpenHelperFactory bypass. If that branch
|
||||
// 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("acct:1", database.messageDao().getById("acct:1")?.id)
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
|
||||
// Regression guard (592a797), pinned at the open site: a steady-state encrypted start converts
|
||||
// nothing in ensureEncrypted, so provideDatabase's encrypted branch must itself have loaded
|
||||
// SQLCipher's native library before the keyed open above could succeed.
|
||||
verify(exactly = 1) { DatabaseEncryption.ensureNativeLibraryLoaded() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plaintextBranchNeverTouchesTheNativeLibrary() = runBlocking<Unit> {
|
||||
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = false, appLock = false))
|
||||
|
||||
mockkObject(DatabaseEncryption)
|
||||
val database = DatabaseModule.provideDatabase(context, provisioner())
|
||||
try {
|
||||
database.messageDao().insertNew(listOf(message("acct:1")))
|
||||
assertEquals("acct:1", database.messageDao().getById("acct:1")?.id)
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
|
||||
// The counterpart to the guard above: the plaintext branch must never construct or load
|
||||
// SQLCipher, so a regression that swapped the branch mapping would show up here too.
|
||||
verify(exactly = 0) { DatabaseEncryption.ensureNativeLibraryLoaded() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encryptedOpenNeverSucceedsIfTheNativeLibraryLoadFails() {
|
||||
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = false))
|
||||
seedEncryptedCacheWithOneRow()
|
||||
|
||||
mockkObject(DatabaseEncryption)
|
||||
// Fault injection: if provideDatabase's encrypted branch ever stopped gating the keyed open on
|
||||
// this load call, the open below would still succeed despite the injected failure. Failing here
|
||||
// instead pins that the open is causally downstream of the load, not just usually preceded by it
|
||||
// — the "wired without a preceding load" regression issue #220 calls out.
|
||||
every { DatabaseEncryption.ensureNativeLibraryLoaded() } throws IllegalStateException("boom")
|
||||
|
||||
val database = DatabaseModule.provideDatabase(context, provisioner())
|
||||
try {
|
||||
assertThrows(Throwable::class.java) {
|
||||
runBlocking { database.messageDao().getById("acct:1") }
|
||||
}
|
||||
} finally {
|
||||
runCatching { database.close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.push
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
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 [PushStatusNotification] — the foreground-notification logic [IdleService]
|
||||
* delegates to (issue #257). `NotificationChannel`/`NotificationCompat` are no-op stubs in the
|
||||
* unit-test `android.jar`, so this is the only place the real channel importance and the push-mode →
|
||||
* text branch can be asserted. It uses the real application `Context` (a `ContextWrapper`, never a
|
||||
* mocked `Context`), mirroring `BatteryOptimizationManagerIntentTest`, and touches no service
|
||||
* lifecycle, Hilt graph, or network — so it is deterministic and side-effect-free beyond registering a
|
||||
* low-importance notification channel.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PushStatusNotificationInstrumentedTest {
|
||||
|
||||
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
|
||||
@Test
|
||||
fun ensureChannel_registersALowImportancePushChannel() {
|
||||
PushStatusNotification.ensureChannel(context)
|
||||
|
||||
val channel = requireNotNull(
|
||||
NotificationManagerCompat.from(context).getNotificationChannel(PushStatusNotification.CHANNEL_ID),
|
||||
) { "push status channel must be registered" }
|
||||
assertEquals(NotificationManager.IMPORTANCE_LOW, channel.importance)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun build_inIdleMode_saysConnectedForInstantDeliveryAndIsAnOngoingServiceNotification() {
|
||||
val notification = PushStatusNotification.build(context, PushMode.IDLE)
|
||||
|
||||
assertEquals(
|
||||
context.getString(R.string.notif_push_status_title),
|
||||
notification.extras.getCharSequence(Notification.EXTRA_TITLE).toString(),
|
||||
)
|
||||
assertEquals(
|
||||
context.getString(R.string.notif_push_status_text),
|
||||
notification.extras.getCharSequence(Notification.EXTRA_TEXT).toString(),
|
||||
)
|
||||
assertTrue(
|
||||
"status notification must be ongoing",
|
||||
(notification.flags and Notification.FLAG_ONGOING_EVENT) != 0,
|
||||
)
|
||||
assertEquals(Notification.CATEGORY_SERVICE, notification.category)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun build_inPollingMode_saysLowBatteryFallback() {
|
||||
val notification = PushStatusNotification.build(context, PushMode.POLLING)
|
||||
|
||||
assertEquals(
|
||||
context.getString(R.string.notif_push_status_text_low_battery),
|
||||
notification.extras.getCharSequence(Notification.EXTRA_TEXT).toString(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun build_afterDataSyncFgsTimeout_saysInstantDeliveryPaused() {
|
||||
// The dataSync FGS runtime-cap fallback (#302): the timed-out text takes precedence over the
|
||||
// mode the service was in when the platform fired onTimeout (still IDLE at that point).
|
||||
val notification = PushStatusNotification.build(context, PushMode.IDLE, timedOut = true)
|
||||
|
||||
assertEquals(
|
||||
context.getString(R.string.notif_push_status_text_timed_out),
|
||||
notification.extras.getCharSequence(Notification.EXTRA_TEXT).toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+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"
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,11 @@ class FakeAccountRepository(
|
||||
accountsFlow.value = accountsFlow.value.filterNot { it.id == id }
|
||||
}
|
||||
|
||||
override suspend fun reorderAccounts(orderedIds: List<String>) {
|
||||
val byId = accountsFlow.value.associateBy { it.id }
|
||||
accountsFlow.value = orderedIds.mapNotNull { byId[it] }
|
||||
}
|
||||
|
||||
override suspend fun resetBackfillProgress(accountId: String?) = Unit
|
||||
}
|
||||
|
||||
@@ -76,6 +81,12 @@ class FakeMailRepository(
|
||||
private val attachments: List<Attachment> = emptyList(),
|
||||
private val downloadedParts: Set<Int> = emptySet(),
|
||||
private val unreadCounts: List<UnreadCount> = emptyList(),
|
||||
drafts: List<Draft> = emptyList(),
|
||||
outbox: List<OutboxMessage> = emptyList(),
|
||||
// When set, every paged query returns this instead of a static page over [messages]. Lets a UI test
|
||||
// drive an explicit LoadState (e.g. refresh = Loading) through collectAsLazyPagingItems to exercise
|
||||
// the empty-state gate (issue #219).
|
||||
private val pagedOverride: Flow<PagingData<Message>>? = null,
|
||||
) : MailRepository {
|
||||
|
||||
val sentMessages = mutableListOf<OutgoingMessage>()
|
||||
@@ -87,15 +98,35 @@ class FakeMailRepository(
|
||||
val expungedIds = mutableListOf<List<String>>()
|
||||
val movedToFolder = mutableListOf<Pair<List<String>, String>>()
|
||||
val replyDrafts = mutableListOf<Pair<String, ReplyMode>>()
|
||||
val canceledOutboxIds = mutableListOf<String>()
|
||||
var retryOutboxCount = 0
|
||||
private set
|
||||
|
||||
override fun observeFolderMessages(accountId: String, folder: String): Flow<List<Message>> =
|
||||
flowOf(messages.filter { it.accountId == accountId && it.folder == folder })
|
||||
|
||||
override fun observeUnifiedFolderMessages(folder: String): Flow<List<Message>> =
|
||||
flowOf(messages.filter { it.folder == folder })
|
||||
// Backed by mutable state so a delete/cancel is reflected in the observed list, letting UI tests
|
||||
// assert the row actually disappears (mirroring the real DB-backed repository's reactivity).
|
||||
private val draftsFlow = MutableStateFlow(drafts)
|
||||
private val outboxFlow = MutableStateFlow(outbox)
|
||||
|
||||
override fun pagedUnifiedFolderMessages(folder: String): Flow<PagingData<Message>> =
|
||||
flowOf(PagingData.from(messages.filter { it.folder == folder && it.inInbox }))
|
||||
pagedOverride ?: flowOf(PagingData.from(messages.filter { it.folder == folder && it.inInbox }))
|
||||
|
||||
override fun pagedFolderMessages(accountId: String, folder: String): Flow<PagingData<Message>> =
|
||||
pagedOverride ?: flowOf(
|
||||
PagingData.from(messages.filter { it.accountId == accountId && it.folder == folder && it.inInbox }),
|
||||
)
|
||||
|
||||
override fun pagedUnifiedSearchMessages(folder: String, query: String): Flow<PagingData<Message>> =
|
||||
pagedOverride ?: flowOf(PagingData.from(messages.filter { it.folder == folder && it.matchesSearch(query) }))
|
||||
|
||||
override fun pagedFolderSearchMessages(
|
||||
accountId: String,
|
||||
folder: String,
|
||||
query: String,
|
||||
): Flow<PagingData<Message>> = pagedOverride ?: flowOf(
|
||||
PagingData.from(
|
||||
messages.filter { it.accountId == accountId && it.folder == folder && it.matchesSearch(query) },
|
||||
),
|
||||
)
|
||||
|
||||
override fun observeFolders(accountId: String): Flow<List<Folder>> = flowOf(
|
||||
folders.filter {
|
||||
@@ -169,9 +200,9 @@ class FakeMailRepository(
|
||||
return sendResult
|
||||
}
|
||||
|
||||
override fun observeDrafts(): Flow<List<Draft>> = flowOf(emptyList())
|
||||
override fun observeDrafts(): Flow<List<Draft>> = draftsFlow
|
||||
|
||||
override suspend fun getDraft(id: String): Draft? = null
|
||||
override suspend fun getDraft(id: String): Draft? = draftsFlow.value.firstOrNull { it.id == id }
|
||||
|
||||
override suspend fun saveDraft(draft: Draft) {
|
||||
savedDrafts += draft
|
||||
@@ -179,13 +210,19 @@ class FakeMailRepository(
|
||||
|
||||
override suspend fun deleteDraft(id: String) {
|
||||
deletedDraftIds += id
|
||||
draftsFlow.value = draftsFlow.value.filterNot { it.id == id }
|
||||
}
|
||||
|
||||
override fun observeOutbox(): Flow<List<OutboxMessage>> = flowOf(emptyList())
|
||||
override fun observeOutbox(): Flow<List<OutboxMessage>> = outboxFlow
|
||||
|
||||
override suspend fun cancelOutboxMessage(id: String) {}
|
||||
override suspend fun cancelOutboxMessage(id: String) {
|
||||
canceledOutboxIds += id
|
||||
outboxFlow.value = outboxFlow.value.filterNot { it.id == id }
|
||||
}
|
||||
|
||||
override suspend fun retryOutbox() {}
|
||||
override suspend fun retryOutbox() {
|
||||
retryOutboxCount++
|
||||
}
|
||||
|
||||
override suspend fun searchServer(query: String, accountId: String?, folder: String) {}
|
||||
|
||||
@@ -198,3 +235,9 @@ class FakeMailSyncer : Syncer {
|
||||
override suspend fun syncAccount(accountId: String): Result<Int> = Result.success(0)
|
||||
override suspend fun syncFolder(accountId: String, folder: String): Result<Int> = Result.success(0)
|
||||
}
|
||||
|
||||
/** Mirrors the paged-search DAO queries' columns so the fake's search pagers filter like production. */
|
||||
private fun Message.matchesSearch(query: String): Boolean = sender.contains(query, ignoreCase = true) ||
|
||||
senderEmail.contains(query, ignoreCase = true) ||
|
||||
subject.contains(query, ignoreCase = true) ||
|
||||
snippet.contains(query, ignoreCase = true)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.accountsetup
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Instrumentation
|
||||
import android.content.Context
|
||||
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.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.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.auth.OutlookAuthManager
|
||||
import org.libremail.domain.model.MailProvider
|
||||
import org.libremail.ui.FakeAccountRepository
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/**
|
||||
* End-to-end UI test for the account-vendor picker used by onboarding and "Add account". Drives the
|
||||
* real [AccountPickerScreen] + [AccountSetupViewModel]: every setup choice is listed, tapping an
|
||||
* app-password vendor routes to [onPickProvider] with that [MailProvider], and tapping "Other"
|
||||
* routes to [onManualSetup]. Tapping Outlook is covered separately, below, guarded by
|
||||
* Espresso-Intents so no real browser ever opens.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AccountPickerScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val context: Context =
|
||||
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private fun setContent(onPickProvider: (MailProvider) -> Unit = {}, onManualSetup: () -> Unit = {}) {
|
||||
val viewModel = AccountSetupViewModel(OutlookAuthManager(context), FakeAccountRepository())
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
AccountPickerScreen(
|
||||
onBack = {},
|
||||
onAccountAdded = {},
|
||||
onPickProvider = onPickProvider,
|
||||
onManualSetup = onManualSetup,
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allSetupChoices_areListed() {
|
||||
setContent()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.account_setup_outlook)).performScrollTo().assertIsDisplayed()
|
||||
MailProvider.entries.forEach { provider ->
|
||||
composeTestRule.onNodeWithText(provider.displayName).performScrollTo().assertIsDisplayed()
|
||||
}
|
||||
composeTestRule.onNodeWithText(string(R.string.account_setup_other)).performScrollTo().assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingAppPasswordProvider_invokesOnPickProvider() {
|
||||
var picked: MailProvider? = null
|
||||
setContent(onPickProvider = { picked = it })
|
||||
|
||||
composeTestRule.onNodeWithText(MailProvider.GMAIL.displayName).performScrollTo().performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) { picked == MailProvider.GMAIL }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingOther_invokesOnManualSetup() {
|
||||
var manualRequested = false
|
||||
setContent(onManualSetup = { manualRequested = true })
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.account_setup_other)).performScrollTo().performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) { manualRequested }
|
||||
}
|
||||
|
||||
/**
|
||||
* Tapping Outlook must fire the browser-launch intent for Microsoft sign-in (#276).
|
||||
* [OutlookAuthManager.createAuthIntent] delegates to AppAuth, whose
|
||||
* `AuthorizationService.getAuthorizationRequestIntent()` never returns a bare browser intent: it
|
||||
* always wraps it in an intent targeting AppAuth's own [AuthorizationManagementActivity], which
|
||||
* only starts the actual browser/Custom Tab once it resumes. That component name is therefore the
|
||||
* one characteristic of the launch that's both guaranteed (every Outlook tap goes through it) and
|
||||
* stable (it doesn't depend on which browser, if any, is installed on the test device) — matching
|
||||
* it both confirms the tap started the AppAuth authorization flow and, by stubbing a canceled
|
||||
* result, stops [AuthorizationManagementActivity] from ever resuming and opening a real browser.
|
||||
* The OAuth redirect, token exchange, and account creation a real sign-in would trigger next are
|
||||
* deliberately out of scope here (see #276).
|
||||
*/
|
||||
@Test
|
||||
fun tappingOutlook_launchesTheAppAuthBrowserIntent() {
|
||||
setContent()
|
||||
|
||||
Intents.init()
|
||||
try {
|
||||
Intents.intending(hasComponent(AuthorizationManagementActivity::class.java.name))
|
||||
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null))
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.account_setup_outlook))
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
Intents.intended(hasComponent(AuthorizationManagementActivity::class.java.name))
|
||||
} finally {
|
||||
Intents.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.accountsetup
|
||||
|
||||
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.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.ext.junit.runners.AndroidJUnit4
|
||||
import jakarta.mail.AuthenticationFailedException
|
||||
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.domain.model.MailProvider
|
||||
import org.libremail.ui.FakeAccountRepository
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/**
|
||||
* End-to-end UI test for the guided app-password setup screen (#29). Drives the real
|
||||
* [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 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 {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
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(
|
||||
repository: FakeAccountRepository = FakeAccountRepository(),
|
||||
onAccountAdded: (String) -> Unit = {},
|
||||
) {
|
||||
val viewModel = AppPasswordViewModel(
|
||||
SavedStateHandle(mapOf(Routes.APP_PASSWORD_ARG_PROVIDER to provider.key)),
|
||||
repository,
|
||||
)
|
||||
composeTestRule.setContent {
|
||||
CompositionLocalProvider(LocalUriHandler provides uriHandler) {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
AppPasswordSetupScreen(onBack = {}, onAccountAdded = onAccountAdded, viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rendersProviderTitle_andCredentialFields() {
|
||||
setContent()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.app_password_title, provider.displayName)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.app_password_email)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.app_password_field)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.app_password_test_and_add)).performScrollTo().assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enteringCredentials_andTappingTestAndAdd_addsAccountAndReportsId() {
|
||||
val repository = FakeAccountRepository()
|
||||
var addedId: String? = null
|
||||
setContent(repository = repository, onAccountAdded = { addedId = it })
|
||||
|
||||
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()
|
||||
|
||||
// A successful add persists via the repository and hands the new account id to the caller.
|
||||
composeTestRule.waitUntil(5_000) {
|
||||
repository.addedAccount?.email == "me@gmail.com" && addedId == "imap:me@gmail.com"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.app_password_open_page, provider.displayName))
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
assertEquals(provider.appPasswordHelpUrl, uriHandler.lastUri)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.compose.format
|
||||
|
||||
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.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
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.ui.theme.LibreMailTheme
|
||||
|
||||
/**
|
||||
* UI tests for the shared font-color / highlight swatch row (#78). [ColorSwatchRow] is
|
||||
* presentational, so it is driven in isolation - independent of the compose formatting toolbar that
|
||||
* hosts it - mirroring how `ParagraphAlignmentControlTest` exercises its control. Every swatch is a
|
||||
* TalkBack-labeled, selectable button, so nodes are addressed by their content description.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ColorSwatchRowTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val red = ColorSwatch(argb = 0xFFD32F2F.toInt(), label = "Red")
|
||||
private val blue = ColorSwatch(argb = 0xFF1976D2.toInt(), label = "Blue")
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private fun setContent(selectedArgb: Int?, onSelect: (Int?) -> Unit = {}) {
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
ColorSwatchRow(swatches = listOf(red, blue), selectedArgb = selectedArgb, onSelect = onSelect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showsNoneEntry_andEverySwatch() {
|
||||
setContent(selectedArgb = null)
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.format_color_none)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithContentDescription(red.label).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithContentDescription(blue.label).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingASwatch_reportsItsArgb() {
|
||||
var picked: Int? = -1
|
||||
setContent(selectedArgb = null) { picked = it }
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(blue.label).performClick()
|
||||
|
||||
assertEquals(blue.argb, picked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingNoneEntry_reportsNull() {
|
||||
var picked: Int? = red.argb
|
||||
setContent(selectedArgb = red.argb) { picked = it }
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.format_color_none)).performClick()
|
||||
|
||||
assertEquals(null, picked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectedSwatch_isMarkedSelected_andOthersAreNot() {
|
||||
setContent(selectedArgb = red.argb)
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(red.label).assertIsSelected()
|
||||
composeTestRule.onNodeWithContentDescription(blue.label).assertIsNotSelected()
|
||||
// With a color selected, the leading "no color" entry is not the selected one.
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.format_color_none)).assertIsNotSelected()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noSelection_marksTheNoneEntrySelected() {
|
||||
setContent(selectedArgb = null)
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.format_color_none)).assertIsSelected()
|
||||
composeTestRule.onNodeWithContentDescription(red.label).assertIsNotSelected()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.drafts
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
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.test.ext.junit.runners.AndroidJUnit4
|
||||
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.domain.model.Draft
|
||||
import org.libremail.ui.FakeMailRepository
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/**
|
||||
* End-to-end UI test for [DraftsScreen] + [DraftsViewModel] over an in-memory [FakeMailRepository]:
|
||||
* the empty state, the subject/recipient/body rendering (with the blank-field fallbacks), opening a
|
||||
* draft, and deleting one (which removes the row and records the deletion in the repository).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DraftsScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private fun draft(id: String, to: String, subject: String, body: String) = Draft(
|
||||
id = id,
|
||||
accountId = "imap:a",
|
||||
to = to,
|
||||
cc = "",
|
||||
subject = subject,
|
||||
body = body,
|
||||
updatedAt = 1_000L,
|
||||
)
|
||||
|
||||
private fun setContent(repo: FakeMailRepository, onOpenDraft: (String) -> Unit = {}) {
|
||||
val viewModel = DraftsViewModel(repo)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
DraftsScreen(onBack = {}, onOpenDraft = onOpenDraft, viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun waitForText(text: String) = composeTestRule.waitUntil(5_000) {
|
||||
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyDrafts_showsEmptyState() {
|
||||
setContent(FakeMailRepository())
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.drafts_empty)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun populatedDraft_showsSubjectRecipientAndBody() {
|
||||
setContent(
|
||||
FakeMailRepository(
|
||||
drafts = listOf(draft("d1", "alice@example.org", "Lunch plans", "See you at noon")),
|
||||
),
|
||||
)
|
||||
waitForText("Lunch plans")
|
||||
|
||||
composeTestRule.onNodeWithText("Lunch plans").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("alice@example.org").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("See you at noon").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankDraft_usesNoSubjectAndNoRecipientFallbacks() {
|
||||
setContent(FakeMailRepository(drafts = listOf(draft("d1", to = "", subject = "", body = ""))))
|
||||
waitForText(string(R.string.draft_no_subject))
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.draft_no_subject)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.draft_no_recipient)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingRow_invokesOnOpenDraft() {
|
||||
var opened: String? = null
|
||||
setContent(
|
||||
FakeMailRepository(drafts = listOf(draft("d1", "alice@example.org", "Lunch plans", "body"))),
|
||||
onOpenDraft = { opened = it },
|
||||
)
|
||||
waitForText("Lunch plans")
|
||||
|
||||
composeTestRule.onNodeWithText("Lunch plans").performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) { opened == "d1" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingDelete_removesRow_andRecordsDeletion() {
|
||||
val repo = FakeMailRepository(
|
||||
drafts = listOf(
|
||||
draft("d1", "alice@example.org", "Lunch plans", "body"),
|
||||
draft("d2", "bob@example.org", "Second draft", "body"),
|
||||
),
|
||||
)
|
||||
setContent(repo)
|
||||
waitForText("Lunch plans")
|
||||
|
||||
// Delete the first draft: the fake removes it from the observed list, so its row disappears.
|
||||
composeTestRule.onAllNodesWithContentDescription(string(R.string.draft_delete))[0].performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) {
|
||||
composeTestRule.onAllNodesWithText("Lunch plans").fetchSemanticsNodes().isEmpty()
|
||||
}
|
||||
assertEquals(listOf("d1"), repo.deletedDraftIds)
|
||||
composeTestRule.onNodeWithText("Second draft").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.lock
|
||||
|
||||
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 tests for the app-lock gate. [LockScreen] is presentational (its biometric prompt is driven by
|
||||
* the caller via [onUnlock]), so it is exercised in isolation: the locked title/body always show, an
|
||||
* optional error string appears only when non-null, and the unlock button reports back.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class LockScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private fun setContent(error: String? = null, onUnlock: () -> Unit = {}) {
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
LockScreen(error = error, onUnlock = onUnlock)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showsLockedTitleBody_andUnlockButton() {
|
||||
setContent()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.app_lock_title)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.app_lock_locked_body)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.app_lock_unlock)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noError_hidesErrorText() {
|
||||
setContent(error = null)
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.app_lock_unlock_failed)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun error_isDisplayed() {
|
||||
setContent(error = string(R.string.app_lock_unlock_failed))
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.app_lock_unlock_failed)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingUnlock_invokesOnUnlock() {
|
||||
var unlocked = false
|
||||
setContent(onUnlock = { unlocked = true })
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.app_lock_unlock)).performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) { unlocked }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -14,7 +14,11 @@ import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.LoadStates
|
||||
import androidx.paging.PagingData
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -284,4 +288,53 @@ class MailboxScreenTest {
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.message_available_offline)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
// Issue #219: on return from the reader the inbox's LazyPagingItems is cold — it presents
|
||||
// itemCount == 0 with refresh == Loading before the window repopulates. The empty-state gate must
|
||||
// hold "No messages yet" back through that window, so the empty state never flashes.
|
||||
@Test
|
||||
fun emptyState_isHidden_whileTheInboxPagerIsStillLoading() {
|
||||
val loadingEmpty = flowOf(
|
||||
PagingData.from(
|
||||
emptyList<Message>(),
|
||||
LoadStates(
|
||||
refresh = LoadState.Loading,
|
||||
prepend = LoadState.NotLoading(endOfPaginationReached = false),
|
||||
append = LoadState.NotLoading(endOfPaginationReached = false),
|
||||
),
|
||||
),
|
||||
)
|
||||
setContent(FakeMailRepository(pagedOverride = loadingEmpty))
|
||||
|
||||
// Assert only the gate — no positive "screen composed" anchor. Under a never-completing
|
||||
// refresh == Loading pager the compose tree never settles deterministically, so no node (not even
|
||||
// the always-present FAB) is reliable to assert *present* here: it flaked as not-displayed
|
||||
// (assertIsDisplayed), not-found (assertExists), and waitForText-timeout across CI runs.
|
||||
// The gate under test (issue #219): the empty state stays hidden while refresh == Loading, so
|
||||
// "No messages yet" never flashes on return from the reader. This is the stable, meaningful
|
||||
// assertion — NoMessagesState is composed only once the pager is "settled" (refresh NotLoading
|
||||
// AND append end-of-pagination reached), which this frozen Loading state never reaches, so the
|
||||
// string can never appear regardless of when the tree happens to settle.
|
||||
composeTestRule.onNodeWithText(string(R.string.mailbox_empty)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
// The flip side of the gate: once the pager settles (refresh done, no further page) with no rows,
|
||||
// the inbox really is empty and "No messages yet" must show.
|
||||
@Test
|
||||
fun emptyState_isShown_onceTheInboxPagerSettlesEmpty() {
|
||||
val settledEmpty = flowOf(
|
||||
PagingData.from(
|
||||
emptyList<Message>(),
|
||||
LoadStates(
|
||||
refresh = LoadState.NotLoading(endOfPaginationReached = false),
|
||||
prepend = LoadState.NotLoading(endOfPaginationReached = true),
|
||||
append = LoadState.NotLoading(endOfPaginationReached = true),
|
||||
),
|
||||
),
|
||||
)
|
||||
setContent(FakeMailRepository(pagedOverride = settledEmpty))
|
||||
|
||||
waitForText(string(R.string.mailbox_empty))
|
||||
composeTestRule.onNodeWithText(string(R.string.mailbox_empty)).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.onboarding
|
||||
|
||||
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 tests for the post-add onboarding prompt. [AddAnotherAccountScreen] is presentational — it just
|
||||
* confirms the add and routes the two choices back to the caller — so it is driven in isolation:
|
||||
* "Add another" returns to the vendor picker and "No" finishes onboarding.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AddAnotherAccountScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private fun setContent(onAddAnother: () -> Unit = {}, onFinish: () -> Unit = {}) {
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
AddAnotherAccountScreen(onAddAnother = onAddAnother, onFinish = onFinish)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showsConfirmation_andBothChoices() {
|
||||
setContent()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_account_added_title)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_prompt)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_yes)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingAddAnother_invokesOnAddAnother() {
|
||||
var addAnother = false
|
||||
setContent(onAddAnother = { addAnother = true })
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_yes)).performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) { addAnother }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingNo_invokesOnFinish() {
|
||||
var finished = false
|
||||
setContent(onFinish = { finished = true })
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) { finished }
|
||||
}
|
||||
}
|
||||
+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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.outbox
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
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.test.ext.junit.runners.AndroidJUnit4
|
||||
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.domain.model.OutboxMessage
|
||||
import org.libremail.ui.FakeMailRepository
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/**
|
||||
* End-to-end UI test for [OutboxScreen] + [OutboxViewModel] over an in-memory [FakeMailRepository]:
|
||||
* the empty state, queued-vs-failed row rendering, and the cancel/retry actions round-tripping
|
||||
* through the repository (cancel removes the row; retry calls back into the repo).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class OutboxScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private fun queued(id: String, subject: String, to: String) =
|
||||
OutboxMessage(id = id, to = to, subject = subject, body = "body", createdAt = 1_000L, lastError = null)
|
||||
|
||||
private fun failed(id: String, subject: String, to: String) =
|
||||
OutboxMessage(id = id, to = to, subject = subject, body = "body", createdAt = 2_000L, lastError = "SMTP 550")
|
||||
|
||||
private fun setContent(repo: FakeMailRepository) {
|
||||
val viewModel = OutboxViewModel(repo)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
OutboxScreen(onBack = {}, viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun waitForText(text: String) = composeTestRule.waitUntil(5_000) {
|
||||
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyOutbox_showsEmptyState_andNoRetryAction() {
|
||||
setContent(FakeMailRepository())
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.outbox_empty)).assertIsDisplayed()
|
||||
// Retry only appears when there is something to retry.
|
||||
composeTestRule.onNodeWithText(string(R.string.outbox_retry)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun populatedOutbox_showsQueuedAndFailedRows_andRetryAction() {
|
||||
setContent(
|
||||
FakeMailRepository(
|
||||
outbox = listOf(
|
||||
queued("q", "Queued mail", "alice@example.org"),
|
||||
failed("f", "Failed mail", "bob@example.org"),
|
||||
),
|
||||
),
|
||||
)
|
||||
waitForText("Queued mail")
|
||||
|
||||
composeTestRule.onNodeWithText("Queued mail").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Failed mail").assertIsDisplayed()
|
||||
// The queued row shows the "queued" status; the failed row shows the "failed" status.
|
||||
composeTestRule.onNodeWithText(string(R.string.outbox_status_queued)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.outbox_status_failed)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.outbox_retry)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingRetry_callsRepositoryRetry() {
|
||||
val repo = FakeMailRepository(outbox = listOf(failed("f", "Failed mail", "bob@example.org")))
|
||||
setContent(repo)
|
||||
waitForText("Failed mail")
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.outbox_retry)).performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) { repo.retryOutboxCount == 1 }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingCancel_removesRow_andRecordsCancellation() {
|
||||
val repo = FakeMailRepository(
|
||||
outbox = listOf(
|
||||
queued("q", "Queued mail", "alice@example.org"),
|
||||
queued("q2", "Second mail", "carol@example.org"),
|
||||
),
|
||||
)
|
||||
setContent(repo)
|
||||
waitForText("Queued mail")
|
||||
|
||||
// Cancel the first row: the fake removes it from the observed outbox, so the row disappears.
|
||||
composeTestRule.onAllNodesWithContentDescription(string(R.string.outbox_cancel))[0].performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) {
|
||||
composeTestRule.onAllNodesWithText("Queued mail").fetchSemanticsNodes().isEmpty()
|
||||
}
|
||||
assertEquals(listOf("q"), repo.canceledOutboxIds)
|
||||
composeTestRule.onNodeWithText("Second mail").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.ui.test.performClick
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -18,6 +19,7 @@ import org.libremail.R
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.domain.model.Attachment
|
||||
import org.libremail.domain.model.Message
|
||||
import org.libremail.domain.model.ReplyMode
|
||||
import org.libremail.ui.FakeMailRepository
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
@@ -44,8 +46,17 @@ class ReaderScreenTest {
|
||||
private fun attachment(partIndex: Int, filename: String) =
|
||||
Attachment(messageId, partIndex, filename, "application/pdf", 1_000L)
|
||||
|
||||
/** Renders [ReaderScreen] for the fixed [message] with the given [attachments] and awaits load. */
|
||||
private fun renderReader(attachments: List<Attachment>, downloadedParts: Set<Int> = emptySet()) {
|
||||
/** The draft id the reader last asked to open compose on (via OpenCompose), or null. */
|
||||
private var openedDraftId: String? = null
|
||||
|
||||
/**
|
||||
* Renders [ReaderScreen] for the fixed [message] with the given [attachments], awaits load, and
|
||||
* returns the backing [FakeMailRepository] so a test can assert which reply drafts were built.
|
||||
*/
|
||||
private fun renderReader(
|
||||
attachments: List<Attachment> = emptyList(),
|
||||
downloadedParts: Set<Int> = emptySet(),
|
||||
): FakeMailRepository {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
|
||||
val repo = FakeMailRepository(
|
||||
messages = listOf(message),
|
||||
@@ -59,12 +70,14 @@ class ReaderScreenTest {
|
||||
)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
ReaderScreen(onBack = {}, onReply = { _, _, _ -> }, viewModel = viewModel)
|
||||
ReaderScreen(onBack = {}, onOpenCompose = { openedDraftId = it }, viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
// The Reply action appears once the message loads, regardless of whether it has attachments.
|
||||
composeTestRule.waitUntil(5_000) {
|
||||
composeTestRule.onAllNodesWithText(attachments.first().filename).fetchSemanticsNodes().isNotEmpty()
|
||||
composeTestRule.onAllNodesWithText(string(R.string.reader_reply)).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -111,4 +124,39 @@ class ReaderScreenTest {
|
||||
composeTestRule.onNodeWithText(seeMore(1)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("b.pdf").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reader_reply_buildsQuotedReplyDraftAndOpensCompose() {
|
||||
val repo = renderReader()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.reader_reply)).performClick()
|
||||
composeTestRule.waitUntil(5_000) { openedDraftId != null }
|
||||
|
||||
// The reader routes through buildReplyDraft (quotes the original + bakes the signature),
|
||||
// not a bare compose prefill (#303), and opens compose on the resulting draft.
|
||||
assertEquals(listOf(messageId to ReplyMode.REPLY), repo.replyDrafts)
|
||||
assertEquals("draft-$messageId", openedDraftId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reader_forward_viaOverflow_buildsForwardDraft() {
|
||||
val repo = renderReader()
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_forward)).performClick()
|
||||
composeTestRule.waitUntil(5_000) { openedDraftId != null }
|
||||
|
||||
assertEquals(listOf(messageId to ReplyMode.FORWARD), repo.replyDrafts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reader_replyAll_viaOverflow_buildsReplyAllDraft() {
|
||||
val repo = renderReader()
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_reply_all)).performClick()
|
||||
composeTestRule.waitUntil(5_000) { openedDraftId != null }
|
||||
|
||||
assertEquals(listOf(messageId to ReplyMode.REPLY_ALL), repo.replyDrafts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
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.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.domain.repository.AccountRepository
|
||||
import org.libremail.reporting.AppVersionProvider
|
||||
import org.libremail.reporting.DebugReport
|
||||
import org.libremail.reporting.DiagnosticsCollector
|
||||
import org.libremail.reporting.ReportKind
|
||||
import org.libremail.reporting.ReportStore
|
||||
import org.libremail.reporting.RingLogBuffer
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* End-to-end UI test for [ProblemReportsScreen] + [ProblemReportsViewModel] backed by a real
|
||||
* file-backed [ReportStore] (temp dir) and a real [DiagnosticsCollector]: the always-present create
|
||||
* button and auto-delete notice, the empty state, the crash/manual row rendering, opening a report,
|
||||
* and the create-report flow which saves a manual report and immediately opens it for review.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ProblemReportsScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val context: Context =
|
||||
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
|
||||
private val storeDir = File(context.cacheDir, "problem-reports-test")
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
storeDir.deleteRecursively()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
storeDir.deleteRecursively()
|
||||
}
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private fun report(id: String, kind: ReportKind) = DebugReport(
|
||||
id = id,
|
||||
createdAtMillis = 1_000L,
|
||||
kind = kind,
|
||||
appVersionName = "1.0",
|
||||
appVersionCode = 1L,
|
||||
androidRelease = "14",
|
||||
androidSdkInt = 34,
|
||||
deviceManufacturer = "Test",
|
||||
deviceModel = "Model",
|
||||
stackTrace = if (kind == ReportKind.CRASH) "boom" else null,
|
||||
settings = emptyMap(),
|
||||
logs = emptyList(),
|
||||
)
|
||||
|
||||
private fun setContent(reports: List<DebugReport>, onOpenReport: (String) -> Unit = {}) {
|
||||
val store = ReportStore(storeDir)
|
||||
reports.forEach(store::save)
|
||||
val collector = DiagnosticsCollector(
|
||||
AppVersionProvider(context),
|
||||
SettingsRepository(context),
|
||||
mockk<AccountRepository> { every { observeAccounts() } returns flowOf(emptyList()) },
|
||||
RingLogBuffer(),
|
||||
)
|
||||
val viewModel = ProblemReportsViewModel(store, collector)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
ProblemReportsScreen(onBack = {}, onOpenReport = onOpenReport, viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun waitForText(text: String) = composeTestRule.waitUntil(5_000) {
|
||||
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noReports_showsEmptyState_withCreateButtonAndAutoDeleteNotice() {
|
||||
setContent(emptyList())
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.reports_empty)).assertIsDisplayed()
|
||||
// The create control and the retention notice are always visible, even with no reports.
|
||||
composeTestRule.onNodeWithText(string(R.string.reports_create)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.report_auto_delete_notice)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun storedReports_showCrashAndManualKindLabels() {
|
||||
setContent(listOf(report("r-crash", ReportKind.CRASH), report("r-manual", ReportKind.MANUAL)))
|
||||
waitForText(string(R.string.report_kind_crash))
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.report_kind_crash)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.report_kind_manual)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingReportRow_invokesOnOpenReport() {
|
||||
var opened: String? = null
|
||||
setContent(listOf(report("r-crash", ReportKind.CRASH)), onOpenReport = { opened = it })
|
||||
waitForText(string(R.string.report_kind_crash))
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.report_kind_crash)).performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) { opened == "r-crash" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingCreate_savesManualReport_andOpensItForReview() {
|
||||
var opened: String? = null
|
||||
setContent(emptyList(), onOpenReport = { opened = it })
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.reports_create)).performClick()
|
||||
|
||||
// Creating a manual report emits its id so the screen opens it straight into review.
|
||||
composeTestRule.waitUntil(5_000) { opened != null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.reporting
|
||||
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
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.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.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.reporting.DebugReport
|
||||
import org.libremail.reporting.ReportKind
|
||||
import org.libremail.reporting.ReportStore
|
||||
import org.libremail.reporting.ReportSubmitter
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* End-to-end UI test for [ReportReviewScreen] + [ReportReviewViewModel] over a real file-backed
|
||||
* [ReportStore] (temp dir) and a [ReportSubmitter] stubbed as disabled (no ingest endpoint, this
|
||||
* repo's default): the disclaimer + comment/email fields render, Submit stays disabled until the
|
||||
* comment reaches the minimum length and the email is valid, and Discard deletes the report and
|
||||
* leaves the screen. The submit-enqueue/upload path (WorkManager) is deliberately out of scope.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ReportReviewScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val context: Context =
|
||||
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
|
||||
private val storeDir = File(context.cacheDir, "report-review-test")
|
||||
private val reportId = "r-1"
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
storeDir.deleteRecursively()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
storeDir.deleteRecursively()
|
||||
}
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private fun report(id: String) = DebugReport(
|
||||
id = id,
|
||||
createdAtMillis = 1_000L,
|
||||
kind = ReportKind.MANUAL,
|
||||
appVersionName = "1.0",
|
||||
appVersionCode = 1L,
|
||||
androidRelease = "14",
|
||||
androidSdkInt = 34,
|
||||
deviceManufacturer = "Test",
|
||||
deviceModel = "Model",
|
||||
stackTrace = null,
|
||||
settings = emptyMap(),
|
||||
logs = emptyList(),
|
||||
)
|
||||
|
||||
private fun setContent(onDone: () -> Unit = {}): ReportStore {
|
||||
val store = ReportStore(storeDir)
|
||||
store.save(report(reportId))
|
||||
val submitter = mockk<ReportSubmitter> { every { isEnabled } returns false }
|
||||
val viewModel = ReportReviewViewModel(
|
||||
SavedStateHandle(mapOf(Routes.REPORT_REVIEW_ARG_ID to reportId)),
|
||||
store,
|
||||
submitter,
|
||||
)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
ReportReviewScreen(onDone = onDone, viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rendersDisclaimer_fieldsAndSubmit() {
|
||||
setContent()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.report_pii_disclaimer_title)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.report_comment_label)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.report_email_label)).performScrollTo().assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.report_submit)).performScrollTo().assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun submit_isDisabled_whenCommentTooShort_orEmailInvalid() {
|
||||
setContent()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.report_submit)).performScrollTo().assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun submit_isEnabled_afterValidCommentAndEmail() {
|
||||
setContent()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.report_comment_label))
|
||||
.performScrollTo()
|
||||
.performTextInput("x".repeat(ReportSubmissionRules.MIN_COMMENT_LENGTH))
|
||||
composeTestRule.onNodeWithText(string(R.string.report_email_label))
|
||||
.performScrollTo()
|
||||
.performTextInput("me@example.com")
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.report_submit)).performScrollTo().assertIsEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingCopy_putsThePayloadOnTheSystemClipboard_andShowsAConfirmation() {
|
||||
// Exercises the #237 migration off LocalClipboardManager/ClipboardManager end-to-end: the
|
||||
// real system clipboard (not a fake) must contain the exact payload after the suspend
|
||||
// LocalClipboard/Clipboard call completes.
|
||||
val store = setContent()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.report_copy)).performScrollTo().performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) {
|
||||
composeTestRule.onAllNodesWithText(string(R.string.report_copied)).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
val clipboardManager = context.getSystemService(ClipboardManager::class.java)
|
||||
val clipText = clipboardManager?.primaryClip?.getItemAt(0)?.text?.toString()
|
||||
assertEquals(store.find(reportId)!!.toSubmissionPayload(), clipText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingDiscard_deletesReport_andLeavesTheScreen() {
|
||||
var done = false
|
||||
val store = setContent(onDone = { done = true })
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.report_discard)).performScrollTo().performClick()
|
||||
|
||||
// Discard removes the row; the screen then auto-navigates away once it observes it is gone.
|
||||
composeTestRule.waitUntil(5_000) { store.find(reportId) == null && done }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.reporting
|
||||
|
||||
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.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
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.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.reporting.DebugReport
|
||||
import org.libremail.reporting.ReportKind
|
||||
import org.libremail.reporting.ReportStore
|
||||
import org.libremail.ui.StartupCrashPrompt
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* E2E for the #255 startup-crash-prompt gating, driving the real [StartupCrashPrompt] composable over a
|
||||
* real file-backed [ReportStore]: a legitimate recent crash pops the dialog exactly once (and never
|
||||
* again after a simulated relaunch reads the persisted `surfaced` flag), while a stale (> 24h) crash
|
||||
* never pops it. A fixed clock keeps the age gate independent of the device wall clock.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class StartupCrashPromptTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val now = 1_000_000_000_000L
|
||||
private val dayMs = 24L * 60 * 60 * 1000
|
||||
private lateinit var dir: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
dir = File(context.cacheDir, "startup_crash_prompt_test_${System.nanoTime()}")
|
||||
dir.deleteRecursively()
|
||||
dir.mkdirs()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
// Unconfined scope runs ReportStore's initial scan inline so a reopened store (simulating a
|
||||
// relaunch) is readable synchronously, as before the scan moved off-thread (#296).
|
||||
private fun store() = ReportStore(dir, CoroutineScope(Dispatchers.Unconfined))
|
||||
|
||||
private fun crash(id: String, createdAt: Long) = DebugReport(
|
||||
id = id,
|
||||
createdAtMillis = createdAt,
|
||||
kind = ReportKind.CRASH,
|
||||
appVersionName = "0.1.0",
|
||||
appVersionCode = 1,
|
||||
androidRelease = "14",
|
||||
androidSdkInt = 34,
|
||||
deviceManufacturer = "Google",
|
||||
deviceModel = "Pixel",
|
||||
stackTrace = null,
|
||||
settings = emptyMap(),
|
||||
logs = emptyList(),
|
||||
)
|
||||
|
||||
private fun viewModel(store: ReportStore) = StartupReportViewModel(store, now = { now })
|
||||
|
||||
/** Renders the prompt against [vmState]; swapping its value simulates a fresh process on relaunch. */
|
||||
private fun render(vmState: MutableState<StartupReportViewModel>) {
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
StartupCrashPrompt(viewModel = vmState.value, onReview = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun awaitDialogShown() = composeTestRule.waitUntil(WAIT_MS) {
|
||||
composeTestRule.onAllNodesWithText(string(R.string.crash_prompt_title)).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
private fun awaitDialogGone() = composeTestRule.waitUntil(WAIT_MS) {
|
||||
composeTestRule.onAllNodesWithText(string(R.string.crash_prompt_title)).fetchSemanticsNodes().isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recentCrash_popsDialogOnce_andNotAgainOnRelaunch() {
|
||||
val store = store()
|
||||
store.save(crash("c", createdAt = now - 60_000L))
|
||||
val vmState = mutableStateOf(viewModel(store))
|
||||
render(vmState)
|
||||
|
||||
// First re-open after the crash: the prompt is offered.
|
||||
awaitDialogShown()
|
||||
composeTestRule.onNodeWithText(string(R.string.crash_prompt_title)).assertIsDisplayed()
|
||||
|
||||
// "Not now" hides it and persistently marks it surfaced (the report itself stays saved).
|
||||
composeTestRule.onNodeWithText(string(R.string.crash_prompt_later)).performClick()
|
||||
awaitDialogGone()
|
||||
assertNotNull(store().find("c"))
|
||||
|
||||
// Relaunch: a fresh store + VM over the same dir reads the persisted flag → no re-nag.
|
||||
composeTestRule.runOnUiThread { vmState.value = viewModel(store()) }
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText(string(R.string.crash_prompt_title)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleCrash_doesNotPopDialog() {
|
||||
val store = store()
|
||||
store.save(crash("old", createdAt = now - dayMs - 60_000L))
|
||||
render(mutableStateOf(viewModel(store)))
|
||||
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText(string(R.string.crash_prompt_title)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun discard_deletesTheReport() {
|
||||
val store = store()
|
||||
store.save(crash("c", createdAt = now - 60_000L))
|
||||
render(mutableStateOf(viewModel(store)))
|
||||
|
||||
awaitDialogShown()
|
||||
composeTestRule.onNodeWithText(string(R.string.crash_prompt_title)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.crash_prompt_discard)).performClick()
|
||||
awaitDialogGone()
|
||||
|
||||
assertNull(store().find("c"))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val WAIT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
+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
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
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.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.performTextInput
|
||||
import androidx.compose.ui.test.performTextReplacement
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.room.Room
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.data.local.AccountDatabase
|
||||
import org.libremail.data.local.entity.AccountEntity
|
||||
import org.libremail.data.local.entity.ServerConfigEmbedded
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/**
|
||||
* End-to-end UI test for [SignatureEditScreen] + [SignatureEditViewModel] over a real in-memory Room
|
||||
* DB and [SignatureRepository]: the new-vs-edit title, saving a freshly entered name (round-tripping
|
||||
* through the repository, which makes the account's first signature its default), loading an existing
|
||||
* signature's name into the field, and saving an edit back to the same row.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SignatureEditScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val context: Context =
|
||||
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
|
||||
private val accountId = "imap:a"
|
||||
|
||||
private lateinit var db: AccountDatabase
|
||||
private lateinit var repository: SignatureRepository
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
|
||||
repository = SignatureRepository(db.signatureDao())
|
||||
// Signatures foreign-key to an account row, so the parent account must exist first.
|
||||
runBlocking {
|
||||
db.accountDao().upsert(
|
||||
AccountEntity(
|
||||
id = accountId,
|
||||
email = "a@example.org",
|
||||
displayName = "A",
|
||||
authType = "PASSWORD_IMAP",
|
||||
imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"),
|
||||
smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
|
||||
|
||||
private fun setContent(signatureId: String? = null, onBack: () -> Unit = {}) {
|
||||
val args = buildMap {
|
||||
put(Routes.SIGNATURE_EDIT_ARG_ACCOUNT, accountId)
|
||||
if (signatureId != null) put(Routes.SIGNATURE_EDIT_ARG_ID, signatureId)
|
||||
}
|
||||
val viewModel = SignatureEditViewModel(SavedStateHandle(args), repository)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
SignatureEditScreen(onBack = onBack, viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun waitForText(text: String) = composeTestRule.waitUntil(5_000) {
|
||||
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newSignature_showsNewTitle() {
|
||||
setContent(signatureId = null)
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.signature_new_title)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newSignature_savingEnteredName_persistsViaRepository() {
|
||||
var backInvoked = false
|
||||
setContent(signatureId = null, onBack = { backInvoked = true })
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.signature_name)).performTextInput("Work")
|
||||
composeTestRule.onNodeWithText(string(R.string.signature_save)).performClick()
|
||||
|
||||
// The save round-trips to the DB: the first signature created becomes the account's default.
|
||||
composeTestRule.waitUntil(5_000) {
|
||||
runBlocking { repository.getDefault(accountId)?.name } == "Work" && backInvoked
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun existingSignature_showsEditTitle_andPrefillsName() {
|
||||
val id = runBlocking { repository.create(accountId, "Personal", "<p>hi</p>") }
|
||||
setContent(signatureId = id)
|
||||
waitForText("Personal")
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.signature_edit_title)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Personal").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun existingSignature_savingRenamedName_updatesTheSameRow() {
|
||||
val id = runBlocking { repository.create(accountId, "Personal", "<p>hi</p>") }
|
||||
setContent(signatureId = id)
|
||||
waitForText("Personal")
|
||||
|
||||
composeTestRule.onNodeWithText("Personal").performTextReplacement("Renamed")
|
||||
composeTestRule.onNodeWithText(string(R.string.signature_save)).performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) {
|
||||
runBlocking { repository.get(id)?.name } == "Renamed"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
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.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
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.data.local.AccountDatabase
|
||||
import org.libremail.data.local.entity.AccountEntity
|
||||
import org.libremail.data.local.entity.ServerConfigEmbedded
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
import org.libremail.ui.navigation.Routes
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
|
||||
/**
|
||||
* End-to-end UI test for [SignaturesScreen] + [SignaturesViewModel] over a real in-memory Room DB
|
||||
* and [SignatureRepository]: the empty state, name + default-badge rendering, making another
|
||||
* signature the default (round-tripping through the DB via the radio), and deleting a signature
|
||||
* (its row disappears as the repository re-emits the account's list).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SignaturesScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val context: Context =
|
||||
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
|
||||
private val accountId = "imap:a"
|
||||
|
||||
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()
|
||||
repository = SignatureRepository(db.signatureDao())
|
||||
// Signatures foreign-key to an account row, so the parent account must exist first.
|
||||
runBlocking {
|
||||
db.accountDao().upsert(
|
||||
AccountEntity(
|
||||
id = accountId,
|
||||
email = "a@example.org",
|
||||
displayName = "A",
|
||||
authType = "PASSWORD_IMAP",
|
||||
imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"),
|
||||
smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
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)
|
||||
|
||||
/** Seeds signatures (the first becomes the account's default) then renders the screen. */
|
||||
private fun setContent(vararg names: String) {
|
||||
runBlocking { names.forEach { repository.create(accountId, it, "<p>$it body</p>") } }
|
||||
val viewModel = SignaturesViewModel(
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun waitForText(text: String) = composeTestRule.waitUntil(5_000) {
|
||||
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noSignatures_showsEmptyState() {
|
||||
setContent()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.signatures_empty)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signatures_renderNames_withExactlyOneDefaultBadge() {
|
||||
setContent("Work", "Personal")
|
||||
waitForText("Work")
|
||||
|
||||
composeTestRule.onNodeWithText("Work").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Personal").assertIsDisplayed()
|
||||
// The first signature created is the account's sole default, so exactly one badge shows.
|
||||
composeTestRule.onAllNodesWithText(string(R.string.signature_default_badge)).assertCountEquals(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingRadioOnNonDefault_makesItTheDefault() {
|
||||
setContent("Work", "Personal")
|
||||
waitForText("Work")
|
||||
|
||||
// "Work" (created first) is the default, so its radio is selected and "Personal"'s is not.
|
||||
composeTestRule.onAllNodes(isSelectable() and isSelected()).assertCountEquals(1)
|
||||
composeTestRule.onNode(isSelectable() and isNotSelected()).performClick()
|
||||
|
||||
// The tap round-trips through the repository/DB: "Personal" is now the account's default.
|
||||
composeTestRule.waitUntil(5_000) {
|
||||
runBlocking { repository.getDefault(accountId)?.name } == "Personal"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingDelete_removesTheRow() {
|
||||
setContent("Work", "Personal")
|
||||
waitForText("Work")
|
||||
|
||||
// Delete the first (default) signature; the repository re-emits, dropping its row.
|
||||
composeTestRule.onAllNodesWithContentDescription(string(R.string.signature_delete))[0].performClick()
|
||||
|
||||
composeTestRule.waitUntil(5_000) {
|
||||
composeTestRule.onAllNodesWithText("Work").fetchSemanticsNodes().isEmpty()
|
||||
}
|
||||
composeTestRule.onNodeWithText("Personal").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<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
|
||||
the debug source set). Hosts ColdOpenCacheProbe in a dedicated ":coldopen" process so an
|
||||
instrumented test can observe a GENUINE cold open of a pre-encrypted cache: a process where nothing
|
||||
has yet loaded SQLCipher's process-global native library. The instrumentation process can't offer
|
||||
that — minting the encrypted fixture (or any earlier test) loads the .so there, masking the 592a797
|
||||
nativeOpen crash. The provider is exported="false" and inert unless ContentResolver.call() targets
|
||||
it, so it has no effect on ordinary debug runs.
|
||||
-->
|
||||
<application>
|
||||
<provider
|
||||
android:name="org.libremail.data.local.coldopen.ColdOpenCacheProbe"
|
||||
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>
|
||||
@@ -0,0 +1,186 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local.coldopen
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.room.Room
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import androidx.sqlite.db.SupportSQLiteOpenHelper
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
|
||||
import org.libremail.data.local.DatabaseEncryption
|
||||
import org.libremail.data.local.DeferredOpenHelperFactory
|
||||
import org.libremail.data.local.LibreMailDatabase
|
||||
|
||||
/**
|
||||
* Test-only harness for issue #221. Lives in the **debug** source set (never in a release APK) and is
|
||||
* declared in the debug manifest with `android:process=":coldopen"`, so its work runs in a SEPARATE app
|
||||
* process from the instrumentation process.
|
||||
*
|
||||
* SQLCipher's `System.loadLibrary("sqlcipher")` is process-global: once the instrumentation process mints
|
||||
* the encrypted fixture (or any earlier test opens a keyed database), the `.so` is loaded there and a
|
||||
* later "cold open" can no longer be observed in that process. That is the exact blind spot behind the
|
||||
* 592a797 crash — a cold start opening an already-encrypted cache with nothing to convert, where the
|
||||
* keyed open reaches `SQLiteConnection.nativeOpen` with the library unloaded and throws
|
||||
* `UnsatisfiedLinkError`. This provider gives the test a pristine process to reproduce it.
|
||||
*
|
||||
* [call] runs in the `:coldopen` process and reports two things back in a [Bundle]:
|
||||
* 1. [KEY_COLD_PROBE] — proof the process is genuinely cold: a keyed open with NO preceding
|
||||
* `System.loadLibrary` must fail at `nativeOpen`. Anything else means the `.so` was already loaded
|
||||
* here, so the isolation premise broke and the "cold" open below would be meaningless.
|
||||
* 2. [KEY_OPEN] — the result of opening the pre-encrypted cache exactly the way production does on a
|
||||
* steady-state encrypted start ([DatabaseProvisioner]'s encrypted branch + [DatabaseModule]'s open
|
||||
* lambda): [DatabaseEncryption.ensureEncrypted] (a no-op on an already-encrypted file),
|
||||
* [DatabaseEncryption.ensureNativeLibraryLoaded] (the 592a797 fix), then a Room open through a
|
||||
* [DeferredOpenHelperFactory] wrapping [SupportOpenHelperFactory]. Reports whether the seeded row
|
||||
* reads back.
|
||||
*
|
||||
* The `DatabaseProvisioner`/`DatabaseModule` objects themselves are not invoked here because they require
|
||||
* MockK-substituted collaborators (`DatabaseKeyStore`/`SettingsRepository`/`AccountDataMigrator` are
|
||||
* final and read the real Keystore/DataStore), and the test APK — hence MockK — is not on a forked app
|
||||
* process's classloader. The provisioner's encrypted-branch decision is instead mirrored line-for-line,
|
||||
* against the real `DatabaseEncryption`, `DeferredOpenHelperFactory` and `SupportOpenHelperFactory`.
|
||||
*/
|
||||
class ColdOpenCacheProbe : ContentProvider() {
|
||||
|
||||
override fun onCreate(): Boolean = true
|
||||
|
||||
override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
|
||||
val out = Bundle()
|
||||
if (method != METHOD_COLD_OPEN) {
|
||||
out.putString(KEY_ERROR, "unknown method: $method")
|
||||
return out
|
||||
}
|
||||
val appContext = requireNotNull(context).applicationContext
|
||||
val bundle = requireNotNull(extras) { "cold-open call requires extras" }
|
||||
val dbName = requireNotNull(bundle.getString(KEY_DB_NAME)) { "missing $KEY_DB_NAME" }
|
||||
val passphrase = requireNotNull(bundle.getString(KEY_PASSPHRASE)) { "missing $KEY_PASSPHRASE" }
|
||||
|
||||
out.putString(KEY_COLD_PROBE, probeKeyedOpenWithoutLoad(appContext))
|
||||
out.putString(KEY_OPEN, openThroughProductionWiring(appContext, dbName, passphrase))
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* A keyed open with NO preceding `System.loadLibrary` — the exact call the pre-592a797 provisioner
|
||||
* reached with the `.so` unloaded. In a genuinely cold process this hits `nativeOpen` and throws
|
||||
* [UnsatisfiedLinkError]. Runs against a THROWAWAY file so a partially-created database can never
|
||||
* disturb the real fixture opened afterwards.
|
||||
*/
|
||||
private fun probeKeyedOpenWithoutLoad(appContext: Context): String {
|
||||
val probeName = "coldopen_probe_$PROBE_NONCE.db"
|
||||
return try {
|
||||
val helper = SupportOpenHelperFactory(PROBE_KEY.toByteArray(Charsets.US_ASCII), null, false)
|
||||
.create(noOpConfiguration(appContext, probeName))
|
||||
try {
|
||||
helper.writableDatabase // forces the keyed nativeOpen — the 592a797 crash surface
|
||||
PROBE_OPENED_UNEXPECTEDLY
|
||||
} finally {
|
||||
runCatching { helper.close() }
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
if (hasUnsatisfiedLink(t)) PROBE_UNSATISFIED_LINK else "$PROBE_OTHER${t.javaClass.name}"
|
||||
} finally {
|
||||
appContext.deleteDatabase(probeName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the pre-encrypted fixture the same way production does on a steady-state encrypted start,
|
||||
* against the real production classes. Returns [OPEN_OK] iff the keyed open succeeds cold and the
|
||||
* seeded row reads back.
|
||||
*/
|
||||
private fun openThroughProductionWiring(appContext: Context, dbName: String, passphrase: String): String {
|
||||
val dbFile = appContext.getDatabasePath(dbName)
|
||||
return try {
|
||||
// Mirror DatabaseProvisioner's encrypted branch: the conversion is a no-op on an already-
|
||||
// encrypted file, so the explicit native-library load is what lets the keyed open succeed.
|
||||
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
|
||||
DatabaseEncryption.ensureNativeLibraryLoaded()
|
||||
val keyBytes = passphrase.toByteArray(Charsets.US_ASCII)
|
||||
val database = Room.databaseBuilder(appContext, LibreMailDatabase::class.java, dbName)
|
||||
// Mirror DatabaseModule.provideDatabase's encrypted open lambda exactly.
|
||||
.openHelperFactory(
|
||||
DeferredOpenHelperFactory { configuration ->
|
||||
SupportOpenHelperFactory(keyBytes, null, false).create(configuration)
|
||||
},
|
||||
)
|
||||
.build()
|
||||
try {
|
||||
val id = runBlocking { database.messageDao().getById(EXPECTED_ROW_ID)?.id }
|
||||
if (id == EXPECTED_ROW_ID) OPEN_OK else "$OPEN_ROWS$id"
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
"$OPEN_FAIL${t.javaClass.name}: ${t.message}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun noOpConfiguration(appContext: Context, name: String): SupportSQLiteOpenHelper.Configuration =
|
||||
SupportSQLiteOpenHelper.Configuration.builder(appContext)
|
||||
.name(name)
|
||||
.callback(object : SupportSQLiteOpenHelper.Callback(CALLBACK_VERSION) {
|
||||
override fun onCreate(db: SupportSQLiteDatabase) = Unit
|
||||
override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit
|
||||
override fun onDowngrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit
|
||||
})
|
||||
.build()
|
||||
|
||||
// Unused ContentProvider surface — this provider exists only for its process-isolated call().
|
||||
override fun query(
|
||||
uri: Uri,
|
||||
projection: Array<out String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?,
|
||||
sortOrder: String?,
|
||||
): Cursor? = null
|
||||
|
||||
override fun getType(uri: Uri): String? = null
|
||||
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
||||
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
||||
|
||||
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int =
|
||||
0
|
||||
|
||||
companion object {
|
||||
/** Appended to the app's `applicationId` to form the provider authority (see the debug manifest). */
|
||||
const val AUTHORITY_SUFFIX = ".coldopen"
|
||||
const val METHOD_COLD_OPEN = "coldOpen"
|
||||
|
||||
const val KEY_DB_NAME = "dbName"
|
||||
const val KEY_PASSPHRASE = "passphrase"
|
||||
const val KEY_COLD_PROBE = "coldProbe"
|
||||
const val KEY_OPEN = "open"
|
||||
const val KEY_ERROR = "error"
|
||||
|
||||
/** The id of the single row the fixture is seeded with; the cold open must read it back. */
|
||||
const val EXPECTED_ROW_ID = "acct:1"
|
||||
|
||||
const val PROBE_UNSATISFIED_LINK = "UNSATISFIED_LINK"
|
||||
const val PROBE_OPENED_UNEXPECTEDLY = "OPENED_UNEXPECTEDLY"
|
||||
const val PROBE_OTHER = "OTHER:"
|
||||
const val OPEN_OK = "OK"
|
||||
const val OPEN_FAIL = "FAIL:"
|
||||
const val OPEN_ROWS = "ROWS:"
|
||||
|
||||
private const val PROBE_KEY = "coldopenprobe"
|
||||
private const val CALLBACK_VERSION = 1
|
||||
private val PROBE_NONCE = System.nanoTime()
|
||||
|
||||
private fun hasUnsatisfiedLink(throwable: Throwable): Boolean {
|
||||
var current: Throwable? = throwable
|
||||
while (current != null) {
|
||||
if (current is UnsatisfiedLinkError) return true
|
||||
current = current.cause
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,11 +77,17 @@ 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.
|
||||
syncScheduler.schedulePeriodicBackfill()
|
||||
syncScheduler.schedulePeriodicPrune()
|
||||
// Delete local crash/problem reports older than a month, only while charging (issue #239).
|
||||
syncScheduler.schedulePeriodicReportPurge()
|
||||
// Run the IMAP IDLE push service only while it has something to do: the push setting is on
|
||||
// AND at least one account exists. This starts it when the first account is added and stops
|
||||
// it when the last is removed, reactively.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.attachment
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.libremail.data.local.dao.DraftDao
|
||||
import org.libremail.data.local.dao.OutboxDao
|
||||
import org.libremail.data.local.toOutgoingAttachments
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Releases the persistable read grants the compose pickers take on attachment / inline-image URIs.
|
||||
* `ComposeScreen` calls `takePersistableUriPermission` on every pick, but nothing released them, so
|
||||
* the app accumulated indefinite read access to every file/photo ever attached and could hit the
|
||||
* per-app persisted-grant cap — after which a silently-swallowed take fails a later draft's image
|
||||
* reload (post-batch security review, Low).
|
||||
*
|
||||
* The picked bytes are copied into the app cache when a message is enqueued
|
||||
* (`MailRepositoryImpl.copyAttachments`), so a grant is only truly needed to reload an image when a
|
||||
* *draft* is reopened. Callers therefore invoke [releaseUnreferenced] once the referencing row is
|
||||
* gone — a draft is deleted, or an outbox message is sent or cancelled — passing that row's URIs.
|
||||
* A URI still referenced by another live draft or outbox row is kept; releasing a grant we do not
|
||||
* actually hold is expected and swallowed.
|
||||
*/
|
||||
@Singleton
|
||||
class AttachmentUriGrants @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val draftDao: DraftDao,
|
||||
private val outboxDao: OutboxDao,
|
||||
) {
|
||||
/**
|
||||
* Releases the persistable grant of each URI in [uris] that no *remaining* draft or outbox row
|
||||
* still references. Call it after deleting the row that referenced them, so that row no longer
|
||||
* counts toward the "still referenced" check.
|
||||
*/
|
||||
suspend fun releaseUnreferenced(uris: Collection<String>) {
|
||||
if (uris.isEmpty()) return
|
||||
unreferencedUris(uris, referencedUris()).forEach(::release)
|
||||
}
|
||||
|
||||
/** Every attachment / inline-image URI still referenced by a draft or a queued outbox message. */
|
||||
private suspend fun referencedUris(): Set<String> {
|
||||
val fromDrafts = draftDao.getAll().flatMap { it.attachments.toOutgoingAttachments() }
|
||||
val fromOutbox = outboxDao.getAll().flatMap { it.attachments.toOutgoingAttachments() }
|
||||
return (fromDrafts + fromOutbox).mapTo(HashSet()) { it.uri }
|
||||
}
|
||||
|
||||
private fun release(uri: String) {
|
||||
// Only a grant we actually hold can be released; a URI never persisted (or already released)
|
||||
// throws SecurityException, which is expected here and deliberately ignored.
|
||||
runCatching {
|
||||
context.contentResolver.releasePersistableUriPermission(
|
||||
Uri.parse(uri),
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The distinct URIs in [candidates] not present in [referenced] — i.e. the grants safe to release.
|
||||
* Kept as a pure top-level function so the release decision is unit-testable without Android types.
|
||||
*/
|
||||
internal fun unreferencedUris(candidates: Collection<String>, referenced: Set<String>): List<String> =
|
||||
candidates.distinct().filterNot { it in referenced }
|
||||
@@ -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,17 +98,19 @@ 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] v2, copied verbatim from the exported Room
|
||||
* schema (`schemas/org.libremail.data.local.AccountDatabase/2.json` — v2 added `accounts.sortOrder`,
|
||||
* issue #164). 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.
|
||||
*/
|
||||
internal val CREATE_TABLE_SQL = mapOf(
|
||||
"accounts" to
|
||||
"CREATE TABLE IF NOT EXISTS `accounts` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, " +
|
||||
"`displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, " +
|
||||
"`displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, " +
|
||||
"`sortOrder` INTEGER NOT NULL DEFAULT 0, `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
|
||||
@@ -168,7 +170,19 @@ class AccountDataMigrator @Inject constructor(
|
||||
val cols = sharedColumns(db, table)
|
||||
db.rawExecSQL("INSERT OR IGNORE INTO `$table` ($cols) SELECT $cols FROM cache.`$table`")
|
||||
}
|
||||
Log.d(TAG, "moved account tables into the account database: $present")
|
||||
// sortOrder (issue #164) is a destination-only column the pre-#111 cache never had, so
|
||||
// the copy above leaves every account at its DEFAULT 0. Give them the same stable
|
||||
// alphabetical initial order ACCOUNT_MIGRATION_1_2 assigns (rank by email), so a
|
||||
// pre-#111 upgrade lands in the order it already showed rather than an undefined
|
||||
// tie-break. Post-#111 installs migrate via ACCOUNT_MIGRATION_1_2 instead and never
|
||||
// reach this copy; either way the user can then drag to reorder.
|
||||
if ("accounts" in present) {
|
||||
db.rawExecSQL(
|
||||
"UPDATE `accounts` SET `sortOrder` = " +
|
||||
"(SELECT COUNT(*) FROM `accounts` AS ranked WHERE ranked.`email` < `accounts`.`email`)",
|
||||
)
|
||||
}
|
||||
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 = 1,
|
||||
version = 2,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class AccountDatabase : RoomDatabase() {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
/**
|
||||
* Migrations for [AccountDatabase] — the non-auth account store split out of the cache database in
|
||||
* issue #111. Kept in a SEPARATE file from the cache database's `MIGRATION_x_y` chain in
|
||||
* `Migrations.kt` on purpose: both databases number their own schema versions from 1, and
|
||||
* `MigrationTest` reflectively pulls every `Migration` val out of the `Migrations.kt` file facade to
|
||||
* assert the cache chain is unbroken — an AccountDatabase migration declared alongside them would
|
||||
* pollute that assertion with a second `1 -> 2`. A distinct file (and the `ACCOUNT_` prefix) keeps
|
||||
* the two chains from colliding at the package level.
|
||||
*/
|
||||
|
||||
/**
|
||||
* AccountDatabase v1 -> v2 (issue #164): add the user-controlled [AccountEntity.sortOrder]. Existing
|
||||
* accounts are given a stable initial order matching the previous alphabetical (`ORDER BY email`)
|
||||
* listing — each account's rank among the others by email — so the order they were already shown in
|
||||
* doesn't shuffle on upgrade. `DEFAULT 0` matches the entity's `@ColumnInfo(defaultValue = "0")` so a
|
||||
* fresh install validates identically to a migrated one (the folders `specialUse` MIGRATION_11_12
|
||||
* pattern); the backfill then overwrites that default with the real rank.
|
||||
*/
|
||||
val ACCOUNT_MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE `accounts` ADD COLUMN `sortOrder` INTEGER NOT NULL DEFAULT 0")
|
||||
db.execSQL(
|
||||
"UPDATE `accounts` SET `sortOrder` = " +
|
||||
"(SELECT COUNT(*) FROM `accounts` AS ranked WHERE ranked.`email` < `accounts`.`email`)",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -10,7 +11,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,25 +105,78 @@ 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 -> {
|
||||
val passphrase = keyStore.resolvePassphrase(appLock)
|
||||
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
|
||||
// Room is about to open the cache with SQLCipher (SupportOpenHelperFactory), so its
|
||||
// native library must already be loaded. ensureEncrypted() above loads it only as a
|
||||
// side effect of an actual plaintext -> encrypted conversion; on a steady-state start
|
||||
// (cache already encrypted, nothing to convert) that no-ops, so without this explicit
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -127,10 +184,38 @@ class DatabaseProvisioner internal constructor(
|
||||
// Encryption was turned back off — decrypt so the default (unkeyed) open succeeds.
|
||||
val passphrase = keyStore.resolvePassphrase(appLock)
|
||||
DatabaseEncryption.ensurePlaintext(dbFile, passphrase)
|
||||
releaseOrphanedAuthSeal()
|
||||
CacheOpenMode.Plaintext
|
||||
}
|
||||
|
||||
else -> CacheOpenMode.Plaintext
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The cache is plaintext now, so a passphrase still sealed by the auth-bound key is an orphan:
|
||||
* nothing needs it to open the database, but its presence keeps the app in the issue-#479
|
||||
* transitional state ([org.libremail.data.security.SealState.AUTH] with `encryptCache` off) —
|
||||
* where losing the auth key (device-lock removal / re-enrollment) forces a needless cache wipe.
|
||||
* Reseal it under the non-auth master key (dropping the auth seal and its Keystore key) so the
|
||||
* passphrase stays recoverable and a later `encryptCache` re-enable reuses it seamlessly.
|
||||
* Best-effort: the session holds the just-used passphrase on this path, but if the reseal still
|
||||
* fails the plaintext open must proceed — the lingering seal is handled defensively everywhere
|
||||
* (EncryptedCacheGuard checks the file, the key-invalidation policy treats it as protected).
|
||||
*/
|
||||
private suspend fun releaseOrphanedAuthSeal() {
|
||||
if (!keyStore.hasAuthSealedPassphrase()) return
|
||||
try {
|
||||
keyStore.sealWithMaster()
|
||||
AppLog.i(TAG, "released orphaned auth seal after decrypt-on-disable (resealed under master key)")
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
AppLog.w(TAG, "failed to release orphaned auth seal after decrypt-on-disable", e)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "DatabaseProvisioner"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user