mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec94ec5305 | ||
|
|
f27bbcb345 | ||
|
|
8d2117c39c | ||
|
|
0b1698d078 | ||
|
|
b1d1b5f94d | ||
|
|
5666b11ecf | ||
|
|
76c256dd8e | ||
|
|
9caed413d7 | ||
|
|
10f0066ed1 | ||
|
|
3c95bba0d2 | ||
|
|
34c65765ba | ||
|
|
a832f6ca3b | ||
|
|
2c40bc6709 | ||
|
|
5d91243451 | ||
|
|
2ebe5fc728 | ||
|
|
6a1c40e7eb | ||
|
|
81b2a7c485 | ||
|
|
3165b40127 | ||
|
|
1286e6cb82 | ||
|
|
681e2546f0 | ||
|
|
718fd00c6d | ||
|
|
cb0f50198e | ||
|
|
421fa6592a | ||
|
|
d20532af91 | ||
|
|
67cfe0a97d | ||
|
|
db1fac4085 | ||
|
|
93de202b38 | ||
|
|
305b836001 | ||
|
|
ba67e67339 | ||
|
|
818c45c280 | ||
|
|
3b3a2fc670 | ||
|
|
d88d3dfbaf | ||
|
|
bac51fbc46 | ||
|
|
7c39730616 | ||
|
|
62e4806353 | ||
|
|
b9dcbdea52 | ||
|
|
889e1c02f6 | ||
|
|
835508c0c0 | ||
|
|
633574203f | ||
|
|
b188c2dba0 | ||
|
|
93c6307027 | ||
|
|
2c5d95184e | ||
|
|
75213f6a55 | ||
|
|
ac875ffe1d | ||
|
|
1c33f8daef | ||
|
|
e2d4915edc | ||
|
|
b1a0eaf805 | ||
|
|
2e90f43181 | ||
|
|
3c0d7ad505 | ||
|
|
a5b40d5a3a | ||
|
|
2662ad404e | ||
|
|
d2d6023084 | ||
|
|
f21f6a126b | ||
|
|
17ed1b2309 | ||
|
|
cef88b9384 | ||
|
|
1194bf284f | ||
|
|
d2d44e0dec | ||
|
|
1aba571ac3 | ||
|
|
8345be43ef | ||
|
|
43bf8da760 | ||
|
|
9192311df8 | ||
|
|
5ead7a8164 | ||
|
|
e5027c45aa | ||
|
|
7a06ab03c0 | ||
|
|
be98acdc8b | ||
|
|
b0997c0a13 | ||
|
|
8aa1d8e03c | ||
|
|
d9d7a46667 | ||
|
|
69f277e1ce | ||
|
|
aabfe3f6a7 | ||
|
|
da41b3f060 | ||
|
|
6a52715704 | ||
|
|
b6d364f732 | ||
|
|
1b6384edec | ||
|
|
1769a4aa62 | ||
|
|
897d4ae32d | ||
|
|
b75b4faa69 | ||
|
|
8fae4e6398 | ||
|
|
2acce326b7 | ||
|
|
05bc0f567b | ||
|
|
4c818d64ea | ||
|
|
22893d327f | ||
|
|
48e427bfa1 | ||
|
|
48a0590a93 | ||
|
|
d254d7b5ab | ||
|
|
c66e6209ed | ||
|
|
bc47c6a1af | ||
|
|
ab226b03b4 | ||
|
|
051f6bf2d5 | ||
|
|
cfca6b9c24 | ||
|
|
0ae33f3a07 | ||
|
|
f9a3b76174 | ||
|
|
b2cd83b188 | ||
|
|
38257f81c6 | ||
|
|
368c90ce3e | ||
|
|
fe3f860e73 | ||
|
|
7e60d32449 | ||
|
|
13dcbdc086 | ||
|
|
0865c1d6e7 | ||
|
|
e4295dab22 | ||
|
|
b43472dda4 | ||
|
|
6b7e752c4b | ||
|
|
f15b7bb3cf | ||
|
|
ff01246c92 | ||
|
|
83ddd13726 | ||
|
|
609d36b0d3 | ||
|
|
44366848d1 | ||
|
|
f27e2cebb4 | ||
|
|
409ada9428 | ||
|
|
efb92593b2 | ||
|
|
0ec809eb49 | ||
|
|
df1e211780 | ||
|
|
62a6c22f10 | ||
|
|
1d654bbeaf | ||
|
|
fbe5457604 | ||
|
|
417f1843a2 | ||
|
|
83981e8b8d | ||
|
|
8ba2f27af2 | ||
|
|
a69bc071d1 | ||
|
|
ba150060cf | ||
|
|
b7b3dd4c4d | ||
|
|
585c70a5db | ||
|
|
c704c7e6af | ||
|
|
e1ad5c0945 | ||
|
|
2b3e84a5a0 | ||
|
|
9d9777738e | ||
|
|
6a27c59672 | ||
|
|
8389db8fcb | ||
|
|
21dd5a2405 | ||
|
|
6e5980e4e0 | ||
|
|
97b71e22f5 | ||
|
|
649d3adc0f | ||
|
|
ab2e5a60f8 | ||
|
|
10fb7ab758 | ||
|
|
c6501ec886 | ||
|
|
66b4305e6d | ||
|
|
7b249ab9d9 | ||
|
|
b4f1082915 | ||
|
|
4724c28f53 | ||
|
|
77914b0a46 | ||
|
|
f1297b6be8 | ||
|
|
36cc49b706 | ||
|
|
3704c66637 | ||
|
|
23b2bf9732 | ||
|
|
172d0f63e8 | ||
|
|
ce950430ea | ||
|
|
d663a5e282 | ||
|
|
268b1105b8 | ||
|
|
9669e07e95 | ||
|
|
b803da5df5 | ||
|
|
e6a89ad07a | ||
|
|
423e2780d5 | ||
|
|
58c8054339 | ||
|
|
350b0e8e74 | ||
|
|
9b0bba7c15 | ||
|
|
5ecf6f3256 | ||
|
|
f606866dd1 | ||
|
|
14ec671c4e | ||
|
|
73823292e2 | ||
|
|
e69bfc502b | ||
|
|
5a301d62dc | ||
|
|
e0184cfb46 | ||
|
|
56c8b98bb6 | ||
|
|
ce21f273a3 | ||
|
|
f2513de025 | ||
|
|
9bc7b4a5b8 | ||
|
|
461e4da0b2 | ||
|
|
66cdc07d31 | ||
|
|
ff878fc6bf | ||
|
|
ccf93ed667 | ||
|
|
9f64f8abb3 | ||
|
|
9763036c70 | ||
|
|
ecf814b66e | ||
|
|
d1c44f52ba | ||
|
|
7eb7289472 | ||
|
|
b17d4d4069 | ||
|
|
47b922cab0 | ||
|
|
2fe8366938 | ||
|
|
9ce89e2672 | ||
|
|
d6b9b342e9 | ||
|
|
72b41eabde | ||
|
|
149e5bcee5 | ||
|
|
236e9117a8 | ||
|
|
e7fd665bb4 | ||
|
|
d00605492a | ||
|
|
9781b30478 | ||
|
|
fe6781745f | ||
|
|
bd5c54d654 | ||
|
|
8e0b447d8f | ||
|
|
e9816809f2 | ||
|
|
11fa0365f9 | ||
|
|
65a8ac3469 | ||
|
|
826d5ad2be | ||
|
|
dcca32a780 | ||
|
|
f9a36b2892 | ||
|
|
8626cd11ef | ||
|
|
e4eda0a840 | ||
|
|
75513969ef | ||
|
|
688e8bef3b | ||
|
|
980243469c | ||
|
|
9ec58be4fb | ||
|
|
426f740359 | ||
|
|
db717dcd51 | ||
|
|
1e21540c6a | ||
|
|
7139390a85 | ||
|
|
62867f3f3d | ||
|
|
6452b47ca2 | ||
|
|
50b91d67d5 | ||
|
|
7ba00be60e | ||
|
|
01578ac2fb | ||
|
|
a4e5f81f37 | ||
|
|
90ce7c1b3c | ||
|
|
9fd55d94d3 | ||
|
|
7467764e43 | ||
|
|
b35d15cada | ||
|
|
ecf1f3ecd9 | ||
|
|
254d192709 | ||
|
|
96b163a117 | ||
|
|
bc9680b014 | ||
|
|
cec68f0d91 | ||
|
|
beae63e144 | ||
|
|
287b0e58ac | ||
|
|
3048525d9b | ||
|
|
dae154431f | ||
|
|
8a5bb72823 | ||
|
|
d684b00e9b | ||
|
|
98b962a8d4 | ||
|
|
6c1cb0f9ef | ||
|
|
12b798c0c8 | ||
|
|
cad9296a84 | ||
|
|
a7a3757378 | ||
|
|
3c3274e4b7 | ||
|
|
aac8a40367 | ||
|
|
e3fc67e771 | ||
|
|
113e96bdec | ||
|
|
1342e2a0f8 | ||
|
|
8ac1997149 | ||
|
|
ecefd1cbcc | ||
|
|
4932764a66 | ||
|
|
fe45537037 | ||
|
|
7a15c0c6ac | ||
|
|
97173d4af0 | ||
|
|
bb45198f23 | ||
|
|
6da985a225 | ||
|
|
90e4e230af | ||
|
|
f643df8164 | ||
|
|
3769b24b3a | ||
|
|
53f95945b1 | ||
|
|
30e0631582 | ||
|
|
470f12d232 | ||
|
|
693c1c8c73 | ||
|
|
beff87440f | ||
|
|
c6ad3fe1d2 | ||
|
|
d9a759b78e | ||
|
|
e5c9433a0e | ||
|
|
446a2eef38 | ||
|
|
88d19db6ef | ||
|
|
5a5f708d82 | ||
|
|
c90a35c403 | ||
|
|
982a30effc | ||
|
|
56e4a1e051 | ||
|
|
01dd7461f5 | ||
|
|
542fff8872 | ||
|
|
c7cb3f60b2 | ||
|
|
fac54b415f | ||
|
|
a0f586f9c2 | ||
|
|
ed490b7188 | ||
|
|
2055df8c21 | ||
|
|
b943246373 | ||
|
|
e0f0db5d67 | ||
|
|
aa25d08dcc | ||
|
|
327b2cae1b | ||
|
|
9067908fef | ||
|
|
18c6b03fc1 | ||
|
|
1821fda728 | ||
|
|
4470a4ba82 | ||
|
|
219aef046a | ||
|
|
65ffef7108 | ||
|
|
04a962a69e | ||
|
|
43bb5e474a | ||
|
|
cd677e7787 | ||
|
|
6715df7f44 | ||
|
|
67732348c2 | ||
|
|
e988dc2928 | ||
|
|
eb6bf6d38e | ||
|
|
af86339bf1 | ||
|
|
baa3ff5956 | ||
|
|
f5875f87b1 | ||
|
|
574db42652 | ||
|
|
a6066a092c | ||
|
|
5f2ef4c1c3 | ||
|
|
b6995bb638 | ||
|
|
37e72c129f | ||
|
|
e3a34eb112 | ||
|
|
9b5692a8ab | ||
|
|
7ecac4ab43 | ||
|
|
1cbe28b18c | ||
|
|
c8f417dd8f | ||
|
|
d3f6d6aea3 | ||
|
|
505fbc59ad | ||
|
|
da1da0ec6b | ||
|
|
64eb97f4c3 | ||
|
|
294023e2b2 | ||
|
|
b205c13a6e | ||
|
|
e09cd395a2 | ||
|
|
9bd4674c9b | ||
|
|
c9b3bdfea0 | ||
|
|
9a4515f905 | ||
|
|
6c6991ace8 | ||
|
|
b95f2c296d | ||
|
|
4849cf233e | ||
|
|
73a6adf27c | ||
|
|
6d701c4b06 | ||
|
|
813be0c10f | ||
|
|
2951672548 | ||
|
|
357a03c062 | ||
|
|
10ad3a40c7 | ||
|
|
6c3046c8dc | ||
|
|
dcbfc907fa | ||
|
|
43973608d6 | ||
|
|
25f9b810f2 | ||
|
|
303dea01e9 | ||
|
|
e8bc54ebaa | ||
|
|
6f66fa065a | ||
|
|
a4d3891140 | ||
|
|
d23af55e4b | ||
|
|
2c1f266fba | ||
|
|
7772da8463 | ||
|
|
352973793f | ||
|
|
9fd8099369 | ||
|
|
f6e6f8f570 | ||
|
|
6e697baa46 | ||
|
|
9794dc03f7 | ||
|
|
a3650ebf34 | ||
|
|
853cb5d052 | ||
|
|
1bef47199c | ||
|
|
482e9a2014 | ||
|
|
a43b55ec25 | ||
|
|
7b551fc3e0 | ||
|
|
ae2eb46b29 | ||
|
|
d812caf025 | ||
|
|
8bb7b60728 | ||
|
|
10e9f81dc1 | ||
|
|
7dc457780d | ||
|
|
8fe5798bf5 | ||
|
|
ff5a8c91d6 | ||
|
|
a19afe9b48 | ||
|
|
1cc4ccca8e | ||
|
|
a930d17786 | ||
|
|
1152299583 | ||
|
|
0647cbf171 | ||
|
|
3243714db9 | ||
|
|
55d008a2e8 | ||
|
|
8e10e73e29 | ||
|
|
0b0fc021fb | ||
|
|
60d0e98fc7 | ||
|
|
a2344ea0ae | ||
|
|
db8eb731f1 | ||
|
|
49d4372918 | ||
|
|
0e69c86c51 | ||
|
|
948cad814f | ||
|
|
7a42a15f8a | ||
|
|
9d547b9d16 | ||
|
|
cd6724f754 | ||
|
|
e6e5e521cf | ||
|
|
fe33dfc53f | ||
|
|
595df26e40 | ||
|
|
c817f978a4 | ||
|
|
51b3208b3b | ||
|
|
7594e45937 | ||
|
|
39e82f99e4 | ||
|
|
67407c1119 | ||
|
|
b767df9fad | ||
|
|
6133bcd951 | ||
|
|
47f2971899 | ||
|
|
98dc13fe9c | ||
|
|
c8600e859a | ||
|
|
fdf8eece62 | ||
|
|
4e3da668f4 | ||
|
|
ddec876e36 | ||
|
|
8adef1e13f | ||
|
|
893f40a21d | ||
|
|
67cdb4e792 | ||
|
|
918571c3c6 | ||
|
|
e9a360d750 | ||
|
|
d201ca24f8 | ||
|
|
3c156673d5 | ||
|
|
ba67205052 | ||
|
|
7b7236f05d | ||
|
|
af1f8197d9 | ||
|
|
2b5257bf6b | ||
|
|
54aa88425a | ||
|
|
fc9c164d3d | ||
|
|
440a2e7ac7 | ||
|
|
47a1d24999 | ||
|
|
e7e40c309e | ||
|
|
07679cbf8c | ||
|
|
79ee9c34ca | ||
|
|
91175a3382 | ||
|
|
6414b0d6a9 | ||
|
|
1ffce863d9 | ||
|
|
25425cb940 | ||
|
|
b50270d8f3 | ||
|
|
fac318dc75 | ||
|
|
7dc9ee24db | ||
|
|
64ce86b372 | ||
|
|
9b87100a15 | ||
|
|
1bce37bb56 | ||
|
|
8e07b7d891 | ||
|
|
96c7c0455d | ||
|
|
ada20598d4 | ||
|
|
61654d7c40 | ||
|
|
01983a3810 | ||
|
|
81061c0d0e | ||
|
|
7a1031a990 | ||
|
|
16bbb97ed2 | ||
|
|
b7b9c7e076 | ||
|
|
639aa4aa42 | ||
|
|
d546d50923 | ||
|
|
31d8e4a4b7 | ||
|
|
cfd81737e3 | ||
|
|
acbd085ab4 | ||
|
|
06d68d73ee | ||
|
|
0fd14a837a | ||
|
|
a8e7a3f49d | ||
|
|
fe4f92112d | ||
|
|
f83284d7e5 | ||
|
|
484cac58b7 | ||
|
|
7c94039a7e | ||
|
|
a412b5163a | ||
|
|
e64f8629da | ||
|
|
07fd03e284 | ||
|
|
32f70c103d | ||
|
|
59c3278259 | ||
|
|
152f308966 | ||
|
|
14d009de32 | ||
|
|
04ff8728da | ||
|
|
0fb7c8cd09 | ||
|
|
b352e470c1 | ||
|
|
946304ab76 | ||
|
|
7a95d43f1a | ||
|
|
58bc4acac5 | ||
|
|
22f2ac5b29 | ||
|
|
90e250d376 | ||
|
|
3bfdf8cef9 | ||
|
|
4de856923b | ||
|
|
5ea19a8581 | ||
|
|
b1550f143b | ||
|
|
6f475c0c52 | ||
|
|
4a56e825e4 | ||
|
|
9d1337bf71 | ||
|
|
d57bdfaace | ||
|
|
01abe1007c | ||
|
|
6b9552fcb4 | ||
|
|
d908f95fd7 | ||
|
|
c6c3d66a9b | ||
|
|
cc21317b5a | ||
|
|
e1e6af3cca | ||
|
|
df1a5ab4b6 | ||
|
|
855d304b4f | ||
|
|
3eac700aa2 | ||
|
|
986216f4d6 | ||
|
|
8be9743b80 | ||
|
|
c84c9ddde7 | ||
|
|
942f45179c | ||
|
|
caeec2bdab | ||
|
|
99a4212804 | ||
|
|
a3b8b9fa9f | ||
|
|
c0c3429c75 | ||
|
|
4e57b19112 | ||
|
|
ef9561f16b | ||
|
|
d92a2ca835 | ||
|
|
323fea7451 | ||
|
|
41e8f00509 | ||
|
|
2167a9d700 | ||
|
|
e81f00714c | ||
|
|
f5e997ba92 | ||
|
|
be04fcb179 | ||
|
|
9ccb62286e | ||
|
|
e47c05c58a | ||
|
|
a2caa4cca5 | ||
|
|
e993540ae6 | ||
|
|
65f00640ba | ||
|
|
77327ea856 | ||
|
|
8a71495e1a | ||
|
|
28d928f6e9 | ||
|
|
ce47c73f33 | ||
|
|
388f46b5b9 | ||
|
|
d3d28d551e | ||
|
|
867ef20e51 | ||
|
|
2c05e7bd00 | ||
|
|
db47162367 | ||
|
|
93ea948045 | ||
|
|
7c91418336 | ||
|
|
80962e3079 | ||
|
|
3047d5c495 | ||
|
|
8893a9e21e | ||
|
|
111af93a09 | ||
|
|
d76f296587 | ||
|
|
547d9e5939 | ||
|
|
9daa3441c8 | ||
|
|
f733a54777 | ||
|
|
b6c0cf1820 | ||
|
|
7c495d03ab | ||
|
|
e03a38001d | ||
|
|
15171637cd | ||
|
|
87472ae937 | ||
|
|
2f43adbc07 | ||
|
|
9804b49979 | ||
|
|
1f190c9402 | ||
|
|
c6eb02d4cd | ||
|
|
1456e15d5c | ||
|
|
6ffd0473e5 | ||
|
|
96ef85159b | ||
|
|
ec7e8e0530 | ||
|
|
4d545f8f58 | ||
|
|
4ec2ccac11 | ||
|
|
18b8aa096f | ||
|
|
20be110537 | ||
|
|
7c775571c9 | ||
|
|
7d8e073587 | ||
|
|
f3e11d4c7c | ||
|
|
913b37661f | ||
|
|
d2bb479ac6 | ||
|
|
174c3fc8fa | ||
|
|
24232bf2e9 | ||
|
|
df4cb7688d | ||
|
|
f1db6b488a | ||
|
|
f358d60584 | ||
|
|
9857fe1711 | ||
|
|
b4331a9bf9 | ||
|
|
c08e9d6fa8 | ||
|
|
470e7fc3ba | ||
|
|
b3bd19cc8c | ||
|
|
95cfa809ef | ||
|
|
3d3f4aec5a | ||
|
|
8303ff769f | ||
|
|
7078dfe806 | ||
|
|
f62849d1fe | ||
|
|
4bb846b726 | ||
|
|
2618680b21 | ||
|
|
3424bcf2e9 | ||
|
|
d13769a9f2 | ||
|
|
0c8b8179b2 | ||
|
|
533f176e88 | ||
|
|
da72415ef3 | ||
|
|
0109273037 | ||
|
|
33eb8f99e5 | ||
|
|
721c2e34a1 | ||
|
|
e06d92d88f | ||
|
|
42e89122bd | ||
|
|
33fc33910a | ||
|
|
dde504f825 | ||
|
|
f5f3637d01 | ||
|
|
bdade8503d | ||
|
|
57ca1c8083 | ||
|
|
32a41f42d3 | ||
|
|
ad9db5cbff | ||
|
|
99e4bd9614 | ||
|
|
239bf618b5 | ||
|
|
58e52421e9 | ||
|
|
96ffeaa0e2 | ||
|
|
81a0723abd | ||
|
|
6103df86a5 | ||
|
|
b97a877fce | ||
|
|
663f6f0007 | ||
|
|
5477cc039a | ||
|
|
ab8e8d7e1e | ||
|
|
49e57482cc | ||
|
|
5f06c3918c | ||
|
|
96471cf13b | ||
|
|
3575ed9450 | ||
|
|
f181c73a56 | ||
|
|
c85e32c9f4 | ||
|
|
f2b98c6dba | ||
|
|
653d5965ed | ||
|
|
01d15fd24f | ||
|
|
35e44318b9 | ||
|
|
3bdafefb65 | ||
|
|
982a23cafd | ||
|
|
c39e977de5 | ||
|
|
24948cb717 | ||
|
|
6b434346ff | ||
|
|
1827f8eb34 | ||
|
|
9c549c9d00 | ||
|
|
f00cd6a259 | ||
|
|
62931bcc53 | ||
|
|
5622d732e9 | ||
|
|
db3c6b5ba1 | ||
|
|
680d4745ed | ||
|
|
8533bd4f99 | ||
|
|
52975bd715 | ||
|
|
bf226c3794 | ||
|
|
ca09708fd1 | ||
|
|
96d9d54cc9 | ||
|
|
02bd714afd | ||
|
|
e57ee3d446 | ||
|
|
08e2f47187 | ||
|
|
de414f7ed6 | ||
|
|
273051e5f5 | ||
|
|
fa68b2eb53 | ||
|
|
3748a64b10 | ||
|
|
2078388e6f | ||
|
|
ea9bad7cff | ||
|
|
ae0b76442a | ||
|
|
341cdb0e61 | ||
|
|
1c9bbcc383 | ||
|
|
c7eda4735c | ||
|
|
5f5ffdaddb | ||
|
|
083ec958a0 | ||
|
|
2b486cb1ef | ||
|
|
86400b124a | ||
|
|
323797b4ee | ||
|
|
9166b0fb3c | ||
|
|
1f253e9488 | ||
|
|
d22fb8d5d0 | ||
|
|
e0f18713aa | ||
|
|
56d826c508 | ||
|
|
e5492b89c8 | ||
|
|
b50436b1ba | ||
|
|
11e74042e2 | ||
|
|
95580b84ea | ||
|
|
9a15334bef | ||
|
|
3aca2d80c0 | ||
|
|
7c534301ee | ||
|
|
14dc5ff2d4 | ||
|
|
14ba28c79c | ||
|
|
598adc154d | ||
|
|
3c0af52c4e | ||
|
|
8ce53816af | ||
|
|
767d4b9f9f | ||
|
|
45b4feeb58 | ||
|
|
e420e3d576 | ||
|
|
726e087795 | ||
|
|
b307ad1b26 | ||
|
|
8918b789e9 | ||
|
|
bda118bc55 | ||
|
|
e6baa751a9 | ||
|
|
9025efee77 | ||
|
|
198e952f61 | ||
|
|
3e6e3d28e1 | ||
|
|
5dcf5991ab | ||
|
|
7b18063630 | ||
|
|
1caf73a3e9 | ||
|
|
a330d77d6d | ||
|
|
efd31de051 | ||
|
|
206756e13c | ||
|
|
7326fec611 | ||
|
|
00619751d7 | ||
|
|
a562ea59b8 | ||
|
|
f490886a8b | ||
|
|
a2ca98e0e8 | ||
|
|
2e26ff7b64 | ||
|
|
42751a6b29 | ||
|
|
97daf76f05 | ||
|
|
e8a9c58d18 | ||
|
|
3f4a81b7ad | ||
|
|
1129ba339b | ||
|
|
e2c76d46c7 | ||
|
|
90b78da8c8 | ||
|
|
6eeee8df54 | ||
|
|
6be51a2bab | ||
|
|
2af3892fec | ||
|
|
2a3febcc79 | ||
|
|
ef6f1540dd | ||
|
|
f52ef7db7e | ||
|
|
fe0facfea5 | ||
|
|
28ade775e3 |
@@ -0,0 +1,92 @@
|
|||||||
|
name: iOS simulator test leg
|
||||||
|
description: Boot a simulator, install the app and run one suite of Maestro flows
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
suite:
|
||||||
|
description: Folder under .maestro (signin or features)
|
||||||
|
required: true
|
||||||
|
name:
|
||||||
|
description: Leg name, used for the artifact
|
||||||
|
required: true
|
||||||
|
app_run_id:
|
||||||
|
description: Take the simulator app from this run instead of the current one
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
flows:
|
||||||
|
description: Comma list of flow names to run instead of the whole suite
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
playwright:
|
||||||
|
description: Install Playwright for the helper's webmail pairing
|
||||||
|
required: false
|
||||||
|
default: "false"
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Download the simulator app (this run)
|
||||||
|
if: inputs.app_run_id == ''
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: simulator-app
|
||||||
|
path: ${{ runner.temp }}
|
||||||
|
|
||||||
|
- name: Download the simulator app (earlier run)
|
||||||
|
if: inputs.app_run_id != ''
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: simulator-app
|
||||||
|
run-id: ${{ inputs.app_run_id }}
|
||||||
|
github-token: ${{ github.token }}
|
||||||
|
path: ${{ runner.temp }}
|
||||||
|
|
||||||
|
- name: Unpack the simulator app
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p "$RUNNER_TEMP/app"
|
||||||
|
tar -C "$RUNNER_TEMP/app" -xzf "$RUNNER_TEMP/simulator-app.tar.gz"
|
||||||
|
echo "APP=$RUNNER_TEMP/app/BulwarkMobile.app" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Boot a simulator
|
||||||
|
shell: bash
|
||||||
|
run: bash scripts/e2e/boot-sim.sh "$DEVICE" "$APPEARANCE" "$APP"
|
||||||
|
|
||||||
|
- name: Install Maestro
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
curl -fsSL "https://get.maestro.mobile.dev" | bash
|
||||||
|
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Install Playwright for the helper
|
||||||
|
if: inputs.playwright == 'true'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p "$RUNNER_TEMP/pw"
|
||||||
|
cd "$RUNNER_TEMP/pw"
|
||||||
|
npm init -y >/dev/null
|
||||||
|
npm install --no-audit --no-fund playwright@1.59.1 >/dev/null
|
||||||
|
npx playwright install chromium >/dev/null
|
||||||
|
echo "PLAYWRIGHT_DIR=$RUNNER_TEMP/pw/node_modules" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Start the test helper
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p "$OUT/helper"
|
||||||
|
HELPER_SHOTS="$OUT/helper" nohup node scripts/e2e/helper.mjs > "$OUT/helper/helper.log" 2>&1 &
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
curl -fs http://127.0.0.1:8787/health >/dev/null && break
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
curl -fs http://127.0.0.1:8787/health
|
||||||
|
|
||||||
|
- name: Run the flows
|
||||||
|
shell: bash
|
||||||
|
run: bash scripts/e2e/run-flows.sh "${{ inputs.suite }}" "${{ inputs.flows }}"
|
||||||
|
|
||||||
|
- name: Upload screenshots, recording and logs
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: e2e-ios-${{ inputs.name }}
|
||||||
|
path: e2e-output/
|
||||||
|
if-no-files-found: ignore
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
name: iOS simulator tests
|
||||||
|
|
||||||
|
# Builds the app for the iOS Simulator (no signing needed) and runs the
|
||||||
|
# Maestro flows in .maestro/ on iPhone and iPad simulators:
|
||||||
|
#
|
||||||
|
# - signin: every sign-in method (.maestro/signin), on an iPhone and an iPad
|
||||||
|
# - features: the app feature by feature (.maestro/features), on iPhone and
|
||||||
|
# iPad in light and dark, plus the iPad in landscape (split layout)
|
||||||
|
#
|
||||||
|
# Each leg signs in with its own account on the test server (e2e-* accounts
|
||||||
|
# on VM 106, password E2E_PASSWORD), so parallel legs never share a mailbox.
|
||||||
|
# scripts/e2e/helper.mjs runs next to the simulator for what a flow can't do
|
||||||
|
# itself: TOTP codes, fresh webmail pairing links, mail sent by userb.
|
||||||
|
#
|
||||||
|
# Artifacts per leg: screenshots, a screen recording, the app log and a JUnit
|
||||||
|
# report. They are public; see scripts/e2e/run-flows.sh for how secrets are
|
||||||
|
# kept out of them.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
app_run_id:
|
||||||
|
description: "Reuse the simulator app from this earlier run instead of building (run id)"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
legs:
|
||||||
|
description: "Legs to run: all, signin, features, perf, or a comma list (signin-phone, features-tablet-dark, ...)"
|
||||||
|
type: string
|
||||||
|
default: all
|
||||||
|
flows:
|
||||||
|
description: "Only these flows (comma list of file names without .yaml); empty runs all"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
perf_base_run_id:
|
||||||
|
description: "perf leg: compare against the simulator app of this run (run id)"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
perf_rounds:
|
||||||
|
description: "perf leg: rounds (each: 3 cold starts and the interaction flow per app)"
|
||||||
|
type: string
|
||||||
|
default: "5"
|
||||||
|
required: false
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
# Per branch and set of legs: a perf run doesn't cancel a feature run.
|
||||||
|
group: e2e-ios-${{ github.ref }}-${{ inputs.legs || 'all' }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
EXPO_NO_TELEMETRY: "1"
|
||||||
|
MAESTRO_CLI_NO_ANALYTICS: "1"
|
||||||
|
# The XCTest driver takes minutes to come up on a cold runner.
|
||||||
|
MAESTRO_DRIVER_STARTUP_TIMEOUT: "600000"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
plan:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
signin: ${{ steps.plan.outputs.signin }}
|
||||||
|
features: ${{ steps.plan.outputs.features }}
|
||||||
|
steps:
|
||||||
|
- id: plan
|
||||||
|
env:
|
||||||
|
LEGS: ${{ inputs.legs || 'all' }}
|
||||||
|
run: |
|
||||||
|
node -e '
|
||||||
|
const legs = (process.env.LEGS || "all").split(",").map((s) => s.trim()).filter(Boolean);
|
||||||
|
const want = (name, group) => legs.includes("all") || legs.includes(group) || legs.includes(name);
|
||||||
|
const signin = ["phone", "tablet"]
|
||||||
|
.map((device) => ({ name: `signin-${device}`, device }))
|
||||||
|
.filter((l) => want(l.name, "signin"));
|
||||||
|
const features = [
|
||||||
|
{ name: "features-phone-light", device: "phone", appearance: "light", orientation: "portrait", account: "e2e-phone-light" },
|
||||||
|
{ name: "features-phone-dark", device: "phone", appearance: "dark", orientation: "portrait", account: "e2e-phone-dark" },
|
||||||
|
{ name: "features-tablet-light", device: "tablet", appearance: "light", orientation: "portrait", account: "e2e-tablet-light" },
|
||||||
|
{ name: "features-tablet-dark", device: "tablet", appearance: "dark", orientation: "portrait", account: "e2e-tablet-dark" },
|
||||||
|
{ name: "features-tablet-landscape", device: "tablet", appearance: "light", orientation: "landscape", account: "e2e-tablet-land" },
|
||||||
|
].filter((l) => want(l.name, "features"));
|
||||||
|
// Push on iOS (.maestro/push): only on request, the app needs the
|
||||||
|
// notification extension (plugins/ios-push).
|
||||||
|
if (legs.includes("push")) {
|
||||||
|
features.push({ name: "push-phone", device: "phone", appearance: "light", orientation: "portrait", account: "e2e-perf", suite: "push" });
|
||||||
|
}
|
||||||
|
const fs = require("fs");
|
||||||
|
fs.appendFileSync(process.env.GITHUB_OUTPUT,
|
||||||
|
`signin=${JSON.stringify({ include: signin })}\nfeatures=${JSON.stringify({ include: features })}\n`);
|
||||||
|
console.log({ signin: signin.map((l) => l.name), features: features.map((l) => l.name) });
|
||||||
|
'
|
||||||
|
|
||||||
|
build:
|
||||||
|
if: inputs.app_run_id == ''
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Cache CocoaPods
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/Library/Caches/CocoaPods
|
||||||
|
~/.cocoapods/repos
|
||||||
|
key: pods-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||||
|
restore-keys: pods-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Install JS dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Generate the iOS project
|
||||||
|
env:
|
||||||
|
# Simulator device tokens belong to the APNs sandbox.
|
||||||
|
APS_ENVIRONMENT: development
|
||||||
|
run: npx expo prebuild --platform ios --clean
|
||||||
|
|
||||||
|
- name: Build for the simulator
|
||||||
|
env:
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
# The bundle times each module's evaluation for the perf leg, and
|
||||||
|
# Metro writes which id is which module (metro.config.js; a branch
|
||||||
|
# without that support ignores both).
|
||||||
|
BULWARK_MODULE_TIMINGS: '1'
|
||||||
|
BULWARK_MODULE_MAP: ${{ runner.temp }}/module-map.json
|
||||||
|
run: |
|
||||||
|
WORKSPACE=$(ls -d ios/*.xcworkspace | head -1)
|
||||||
|
SCHEME=$(basename "$WORKSPACE" .xcworkspace)
|
||||||
|
mkdir -p e2e-build
|
||||||
|
# Release bundles the JS into the app, so no Metro server is needed.
|
||||||
|
# arm64 only: the runner is Apple silicon, and a second simulator
|
||||||
|
# architecture would double the build time.
|
||||||
|
if ! xcodebuild build \
|
||||||
|
-workspace "$WORKSPACE" \
|
||||||
|
-scheme "$SCHEME" \
|
||||||
|
-configuration Release \
|
||||||
|
-sdk iphonesimulator \
|
||||||
|
-destination 'generic/platform=iOS Simulator' \
|
||||||
|
-derivedDataPath build \
|
||||||
|
ARCHS=arm64 \
|
||||||
|
CODE_SIGN_STYLE=Manual \
|
||||||
|
CODE_SIGN_IDENTITY=- \
|
||||||
|
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER= \
|
||||||
|
> e2e-build/xcodebuild.log 2>&1; then
|
||||||
|
grep -E "error:|fatal error" e2e-build/xcodebuild.log | head -n 50 || true
|
||||||
|
tail -n 80 e2e-build/xcodebuild.log
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Signed ad hoc ("Sign to Run Locally"): no certificate needed, and
|
||||||
|
# Xcode embeds the simulator entitlements the keychain needs. An
|
||||||
|
# unsigned build has none, and every expo-secure-store call fails
|
||||||
|
# with "A required entitlement isn't present".
|
||||||
|
APP_PATH=build/Build/Products/Release-iphonesimulator/BulwarkMobile.app
|
||||||
|
echo "Simulated entitlements:"
|
||||||
|
xcrun otool -X -s __TEXT __entitlements "$APP_PATH/BulwarkMobile" | head -20 || true
|
||||||
|
codesign -d --entitlements - "$APP_PATH" 2>&1 | head -20 || true
|
||||||
|
# Packed as a tar: artifacts drop the executable bit, which would
|
||||||
|
# leave a reused app unable to launch.
|
||||||
|
tar -C build/Build/Products/Release-iphonesimulator -cf "$RUNNER_TEMP/simulator-app.tar" BulwarkMobile.app
|
||||||
|
if [ -f "$RUNNER_TEMP/module-map.json" ]; then
|
||||||
|
tar -C "$RUNNER_TEMP" -rf "$RUNNER_TEMP/simulator-app.tar" module-map.json
|
||||||
|
else
|
||||||
|
echo "no module map (branch without module timings)"
|
||||||
|
fi
|
||||||
|
gzip -f "$RUNNER_TEMP/simulator-app.tar"
|
||||||
|
ls -la "$RUNNER_TEMP/simulator-app.tar.gz"
|
||||||
|
|
||||||
|
- name: Upload the simulator app
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: simulator-app
|
||||||
|
path: ${{ runner.temp }}/simulator-app.tar.gz
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Upload the build log
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: xcodebuild-log
|
||||||
|
path: e2e-build/
|
||||||
|
|
||||||
|
signin:
|
||||||
|
needs: [plan, build]
|
||||||
|
if: >-
|
||||||
|
always() && !cancelled() &&
|
||||||
|
(needs.build.result == 'success' || needs.build.result == 'skipped') &&
|
||||||
|
fromJson(needs.plan.outputs.signin).include[0] != null
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 150
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: read
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix: ${{ fromJson(needs.plan.outputs.signin) }}
|
||||||
|
env:
|
||||||
|
OUT: ${{ github.workspace }}/e2e-output
|
||||||
|
DEVICE: ${{ matrix.device }}
|
||||||
|
APPEARANCE: light
|
||||||
|
ORIENTATION: portrait
|
||||||
|
E2E_SERVER: ${{ vars.E2E_SERVER }}
|
||||||
|
E2E_WEBMAIL: https://bulwark.internal.bulwarkmail.org
|
||||||
|
E2E_ACCOUNT: e2e-login-${{ matrix.device }}
|
||||||
|
E2E_ACCOUNT2: e2e-second-${{ matrix.device }}
|
||||||
|
E2E_TOTP_ACCOUNT: e2e-totp-${{ matrix.device }}
|
||||||
|
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
|
||||||
|
E2E_USERB_PASSWORD: ${{ secrets.E2E_USERB_PASSWORD }}
|
||||||
|
E2E_TOTP_SECRET: ${{ secrets.E2E_TOTP_SECRET }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ./.github/actions/e2e-ios-leg
|
||||||
|
with:
|
||||||
|
suite: signin
|
||||||
|
app_run_id: ${{ inputs.app_run_id }}
|
||||||
|
flows: ${{ inputs.flows }}
|
||||||
|
playwright: "true"
|
||||||
|
name: ${{ matrix.name }}
|
||||||
|
|
||||||
|
features:
|
||||||
|
needs: [plan, build]
|
||||||
|
if: >-
|
||||||
|
always() && !cancelled() &&
|
||||||
|
(needs.build.result == 'success' || needs.build.result == 'skipped') &&
|
||||||
|
fromJson(needs.plan.outputs.features).include[0] != null
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 120
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: read
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix: ${{ fromJson(needs.plan.outputs.features) }}
|
||||||
|
env:
|
||||||
|
OUT: ${{ github.workspace }}/e2e-output
|
||||||
|
DEVICE: ${{ matrix.device }}
|
||||||
|
APPEARANCE: ${{ matrix.appearance }}
|
||||||
|
ORIENTATION: ${{ matrix.orientation }}
|
||||||
|
E2E_SERVER: ${{ vars.E2E_SERVER }}
|
||||||
|
E2E_WEBMAIL: https://bulwark.internal.bulwarkmail.org
|
||||||
|
E2E_ACCOUNT: ${{ matrix.account }}
|
||||||
|
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
|
||||||
|
E2E_USERB_PASSWORD: ${{ secrets.E2E_USERB_PASSWORD }}
|
||||||
|
E2E_TOTP_SECRET: ${{ secrets.E2E_TOTP_SECRET }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ./.github/actions/e2e-ios-leg
|
||||||
|
with:
|
||||||
|
suite: ${{ matrix.suite || 'features' }}
|
||||||
|
app_run_id: ${{ inputs.app_run_id }}
|
||||||
|
flows: ${{ inputs.flows }}
|
||||||
|
playwright: "false"
|
||||||
|
name: ${{ matrix.name }}
|
||||||
|
|
||||||
|
# Part 2 of docs/ios-test-2026-10.md: timing marks from the app
|
||||||
|
# (src/lib/perf-marks.ts), read by scripts/e2e/perf.mjs. Compares the app
|
||||||
|
# of this run (or app_run_id) with the app of perf_base_run_id, alternating
|
||||||
|
# them on one simulator so both sides share the runner.
|
||||||
|
perf:
|
||||||
|
needs: [plan, build]
|
||||||
|
if: >-
|
||||||
|
always() && !cancelled() &&
|
||||||
|
(needs.build.result == 'success' || needs.build.result == 'skipped') &&
|
||||||
|
contains(inputs.legs, 'perf')
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 120
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: read
|
||||||
|
env:
|
||||||
|
OUT: ${{ github.workspace }}/e2e-output
|
||||||
|
DEVICE: phone
|
||||||
|
APPEARANCE: light
|
||||||
|
ORIENTATION: portrait
|
||||||
|
E2E_SERVER: ${{ vars.E2E_SERVER }}
|
||||||
|
E2E_ACCOUNT: e2e-bulk
|
||||||
|
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download the app under test (this run)
|
||||||
|
if: inputs.app_run_id == ''
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: simulator-app
|
||||||
|
path: ${{ runner.temp }}/cand
|
||||||
|
- name: Download the app under test (earlier run)
|
||||||
|
if: inputs.app_run_id != ''
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: simulator-app
|
||||||
|
run-id: ${{ inputs.app_run_id }}
|
||||||
|
github-token: ${{ github.token }}
|
||||||
|
path: ${{ runner.temp }}/cand
|
||||||
|
- name: Download the baseline app
|
||||||
|
if: inputs.perf_base_run_id != ''
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: simulator-app
|
||||||
|
run-id: ${{ inputs.perf_base_run_id }}
|
||||||
|
github-token: ${{ github.token }}
|
||||||
|
path: ${{ runner.temp }}/base
|
||||||
|
|
||||||
|
- name: Unpack
|
||||||
|
run: |
|
||||||
|
for side in cand base; do
|
||||||
|
[ -f "$RUNNER_TEMP/$side/simulator-app.tar.gz" ] || continue
|
||||||
|
mkdir -p "$RUNNER_TEMP/$side/app"
|
||||||
|
tar -C "$RUNNER_TEMP/$side/app" -xzf "$RUNNER_TEMP/$side/simulator-app.tar.gz"
|
||||||
|
done
|
||||||
|
echo "APP=$RUNNER_TEMP/cand/app/BulwarkMobile.app" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Boot a simulator
|
||||||
|
run: bash scripts/e2e/boot-sim.sh "$DEVICE" "$APPEARANCE" "$APP"
|
||||||
|
|
||||||
|
- name: Install Maestro
|
||||||
|
run: |
|
||||||
|
curl -fsSL "https://get.maestro.mobile.dev" | bash
|
||||||
|
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Sign in once
|
||||||
|
run: |
|
||||||
|
mkdir -p "$OUT/screenshots"
|
||||||
|
cd "$OUT/screenshots"
|
||||||
|
# Warm-up for the driver, then the sign-in (keychain survives reinstalls).
|
||||||
|
maestro --device "$UDID" test "$GITHUB_WORKSPACE/.maestro/launch.yaml"
|
||||||
|
maestro --device "$UDID" test -e "SERVER=$E2E_SERVER" -e "PASSWORD=$E2E_PASSWORD" \
|
||||||
|
-e "ACCOUNT=$E2E_ACCOUNT" -e "ORIENTATION=portrait" "$GITHUB_WORKSPACE/.maestro/perf/sign-in.yaml"
|
||||||
|
|
||||||
|
- name: Measure
|
||||||
|
run: |
|
||||||
|
APPS=(--app "cand=$RUNNER_TEMP/cand/app/BulwarkMobile.app")
|
||||||
|
if [ -d "$RUNNER_TEMP/base/app/BulwarkMobile.app" ]; then
|
||||||
|
APPS=(--app "base=$RUNNER_TEMP/base/app/BulwarkMobile.app" "${APPS[@]}")
|
||||||
|
fi
|
||||||
|
node scripts/e2e/perf.mjs "${APPS[@]}" --rounds "${{ inputs.perf_rounds || 5 }}" --cold 5 \
|
||||||
|
--flows "$(ls "$GITHUB_WORKSPACE"/.maestro/perf/[0-9]-*.yaml | paste -sd, -)" --out "$OUT"
|
||||||
|
|
||||||
|
- name: Upload results
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: e2e-ios-perf
|
||||||
|
path: e2e-output/
|
||||||
|
if-no-files-found: ignore
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Release Android APK
|
name: Release Android
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
@@ -137,7 +137,7 @@ jobs:
|
|||||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
chmod +x ./gradlew
|
chmod +x ./gradlew
|
||||||
./gradlew assembleRelease --no-daemon
|
./gradlew assembleGithubRelease --no-daemon
|
||||||
|
|
||||||
- name: Put apksigner on the PATH
|
- name: Put apksigner on the PATH
|
||||||
run: |
|
run: |
|
||||||
@@ -188,7 +188,7 @@ jobs:
|
|||||||
# Oldest signer first: apksigner uses it for v2, and the rotated key
|
# Oldest signer first: apksigner uses it for v2, and the rotated key
|
||||||
# for v3 from API 28. Without --rotation-min-sdk-version it would
|
# for v3 from API 28. Without --rotation-min-sdk-version it would
|
||||||
# only rotate on Android 13+ (v3.1) and leave 9-12L on the debug key.
|
# only rotate on Android 13+ (v3.1) and leave 9-12L on the debug key.
|
||||||
APK=android/app/build/outputs/apk/release/app-release.apk
|
APK=android/app/build/outputs/apk/github/release/app-github-release.apk
|
||||||
apksigner sign \
|
apksigner sign \
|
||||||
--v4-signing-enabled false \
|
--v4-signing-enabled false \
|
||||||
--ks android/app/debug.keystore --ks-key-alias androiddebugkey \
|
--ks android/app/debug.keystore --ks-key-alias androiddebugkey \
|
||||||
@@ -210,7 +210,7 @@ jobs:
|
|||||||
ROTATED: ${{ steps.rotation.outputs.rotated }}
|
ROTATED: ${{ steps.rotation.outputs.rotated }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
APK=android/app/build/outputs/apk/release/app-release.apk
|
APK=android/app/build/outputs/apk/github/release/app-github-release.apk
|
||||||
apksigner verify --verbose --print-certs "$APK"
|
apksigner verify --verbose --print-certs "$APK"
|
||||||
# build-tools 36 prints "Signer #1 certificate SHA-256 digest: …",
|
# build-tools 36 prints "Signer #1 certificate SHA-256 digest: …",
|
||||||
# 37 prints "V3.0 Signer: certificate …" and "V2 Signer: …".
|
# 37 prints "V3.0 Signer: certificate …" and "V2 Signer: …".
|
||||||
@@ -248,12 +248,12 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
VERSION=$(cat VERSION | tr -d '[:space:]')
|
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||||
SRC=android/app/build/outputs/apk/release/app-release.apk
|
SRC=android/app/build/outputs/apk/github/release/app-github-release.apk
|
||||||
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.apk"
|
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.apk"
|
||||||
cp "$SRC" "$DEST"
|
cp "$SRC" "$DEST"
|
||||||
sha256sum "$DEST" > "$DEST.sha256"
|
sha256sum "$DEST" > "$DEST.sha256"
|
||||||
|
|
||||||
MAPPING_SRC=android/app/build/outputs/mapping/release/mapping.txt
|
MAPPING_SRC=android/app/build/outputs/mapping/githubRelease/mapping.txt
|
||||||
MAPPING=""
|
MAPPING=""
|
||||||
if [ -f "$MAPPING_SRC" ]; then
|
if [ -f "$MAPPING_SRC" ]; then
|
||||||
MAPPING="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}-mapping.txt"
|
MAPPING="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}-mapping.txt"
|
||||||
@@ -299,12 +299,12 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
|
# A manual run with no tag only builds: it must not replace the APK
|
||||||
|
# of the latest release with one from another commit.
|
||||||
if [ "${{ github.event_name }}" = "release" ]; then
|
if [ "${{ github.event_name }}" = "release" ]; then
|
||||||
TAG="${{ github.event.release.tag_name }}"
|
TAG="${{ github.event.release.tag_name }}"
|
||||||
elif [ -n "${{ github.event.inputs.attach_to_release_tag }}" ]; then
|
|
||||||
TAG="${{ github.event.inputs.attach_to_release_tag }}"
|
|
||||||
else
|
else
|
||||||
TAG=$(gh release list --limit 1 --json tagName --jq '.[0].tagName' || true)
|
TAG="${{ github.event.inputs.attach_to_release_tag }}"
|
||||||
fi
|
fi
|
||||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
if [ -n "$TAG" ]; then
|
if [ -n "$TAG" ]; then
|
||||||
@@ -319,3 +319,150 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.target_tag.outputs.tag }}
|
tag_name: ${{ steps.target_tag.outputs.tag }}
|
||||||
files: ${{ steps.artifact.outputs.files }}
|
files: ${{ steps.artifact.outputs.files }}
|
||||||
|
|
||||||
|
# The Google Play bundle (docs/android-release.md, "Google Play"). It is
|
||||||
|
# signed with the release key, which Play knows as the upload key; Play
|
||||||
|
# re-signs what it delivers with its own app signing key. A separate job so
|
||||||
|
# the GitHub APK isn't held up by the three-ABI native build.
|
||||||
|
play-bundle:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 75
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
env:
|
||||||
|
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||||
|
steps:
|
||||||
|
- name: Require the release signing secrets
|
||||||
|
env:
|
||||||
|
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
missing=""
|
||||||
|
[ -n "$KEYSTORE_BASE64" ] || missing="$missing ANDROID_KEYSTORE_BASE64"
|
||||||
|
[ -n "$KEYSTORE_PASSWORD" ] || missing="$missing ANDROID_KEYSTORE_PASSWORD"
|
||||||
|
[ -n "$KEY_ALIAS" ] || missing="$missing ANDROID_KEY_ALIAS"
|
||||||
|
[ -n "$KEY_PASSWORD" ] || missing="$missing ANDROID_KEY_PASSWORD"
|
||||||
|
if [ -n "$missing" ]; then
|
||||||
|
echo "::error title=Release signing is not configured::Missing repository secrets:$missing. See docs/android-release.md."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 17
|
||||||
|
cache: gradle
|
||||||
|
|
||||||
|
- uses: android-actions/setup-android@v3
|
||||||
|
with:
|
||||||
|
packages: platform-tools
|
||||||
|
|
||||||
|
- name: Install JS dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Decode and check the upload key
|
||||||
|
id: key
|
||||||
|
env:
|
||||||
|
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
KEYSTORE="$RUNNER_TEMP/release.keystore"
|
||||||
|
CERT="$RUNNER_TEMP/release-cert.der"
|
||||||
|
echo "$KEYSTORE_BASE64" | base64 -d > "$KEYSTORE"
|
||||||
|
keytool -exportcert -keystore "$KEYSTORE" -alias "$KEY_ALIAS" \
|
||||||
|
-storepass:env KEYSTORE_PASSWORD -file "$CERT"
|
||||||
|
CERT_SHA256=$(sha256sum "$CERT" | cut -d' ' -f1)
|
||||||
|
if [ "$CERT_SHA256" = "$DEBUG_CERT_SHA256" ]; then
|
||||||
|
echo "::error title=Upload key is a debug key::ANDROID_KEYSTORE_BASE64 holds the Android debug key."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "store_file=$KEYSTORE" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "cert_sha256=$CERT_SHA256" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Play splits a bundle per ABI, so the extra architectures cost a phone
|
||||||
|
# nothing and let 32-bit ARM phones and x86_64 Chromebooks install it.
|
||||||
|
- name: Build the Play bundle
|
||||||
|
working-directory: android
|
||||||
|
env:
|
||||||
|
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_FILE: ${{ steps.key.outputs.store_file }}
|
||||||
|
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
chmod +x ./gradlew
|
||||||
|
./gradlew bundlePlayRelease --no-daemon -PreactNativeArchitectures=armeabi-v7a,arm64-v8a,x86_64
|
||||||
|
|
||||||
|
# Play rejects a bundle signed with another key than the registered
|
||||||
|
# upload key, and its policies reject a self-updater and the media
|
||||||
|
# permission. Catch all three here instead of in the Play Console.
|
||||||
|
- name: Check the bundle
|
||||||
|
env:
|
||||||
|
RELEASE_CERT_SHA256: ${{ steps.key.outputs.cert_sha256 }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
AAB=android/app/build/outputs/bundle/playRelease/app-play-release.aab
|
||||||
|
SIGNER=$(keytool -printcert -jarfile "$AAB" | sed -n 's/^[[:space:]]*SHA256: //p' | head -n 1 | tr -d ':' | tr 'A-F' 'a-f')
|
||||||
|
echo "Bundle signer SHA-256: $SIGNER"
|
||||||
|
if [ "$SIGNER" != "$RELEASE_CERT_SHA256" ]; then
|
||||||
|
echo "::error title=Wrong bundle signer::Expected the upload key $RELEASE_CERT_SHA256, got '$SIGNER'."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
MANIFEST=$(unzip -p "$AAB" base/manifest/AndroidManifest.xml | tr -c '[:print:]' '\n')
|
||||||
|
if ! grep -q 'org.bulwarkmail.mobile' <<< "$MANIFEST"; then
|
||||||
|
echo "::error title=Wrong package::The bundle is not org.bulwarkmail.mobile."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for permission in REQUEST_INSTALL_PACKAGES READ_MEDIA_IMAGES READ_MEDIA_VIDEO RECORD_AUDIO; do
|
||||||
|
if grep -q "android.permission.$permission" <<< "$MANIFEST"; then
|
||||||
|
echo "::error title=Permission not allowed on Play::The Play bundle declares $permission."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Name the bundle and R8 mapping with version
|
||||||
|
id: artifact
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||||
|
AAB="bulwark-mobile-play-${VERSION}-${GITHUB_SHA::7}.aab"
|
||||||
|
MAPPING="bulwark-mobile-play-${VERSION}-${GITHUB_SHA::7}-mapping.txt"
|
||||||
|
cp android/app/build/outputs/bundle/playRelease/app-play-release.aab "$AAB"
|
||||||
|
cp android/app/build/outputs/mapping/playRelease/mapping.txt "$MAPPING"
|
||||||
|
echo "aab=$AAB" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "mapping=$MAPPING" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ steps.artifact.outputs.aab }}
|
||||||
|
path: |
|
||||||
|
${{ steps.artifact.outputs.aab }}
|
||||||
|
${{ steps.artifact.outputs.mapping }}
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
# Runs once PLAY_SERVICE_ACCOUNT_JSON is set (Play only accepts API
|
||||||
|
# uploads after the first bundle was uploaded by hand in the Console).
|
||||||
|
# Every release goes out to the internal and closed (beta) testers right
|
||||||
|
# away; closed testing may wait for Google's review first.
|
||||||
|
- name: Upload to Google Play (internal and closed testing)
|
||||||
|
if: env.PLAY_SERVICE_ACCOUNT_JSON != ''
|
||||||
|
uses: r0adkll/upload-google-play@v1
|
||||||
|
with:
|
||||||
|
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||||
|
packageName: org.bulwarkmail.mobile
|
||||||
|
releaseFiles: ${{ steps.artifact.outputs.aab }}
|
||||||
|
mappingFile: ${{ steps.artifact.outputs.mapping }}
|
||||||
|
releaseName: ${{ steps.artifact.outputs.version }}
|
||||||
|
tracks: internal,beta
|
||||||
|
status: completed
|
||||||
|
|||||||
@@ -59,9 +59,6 @@ jobs:
|
|||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Generate the iOS project
|
|
||||||
run: npx expo prebuild --platform ios --clean
|
|
||||||
|
|
||||||
- name: Check for signing credentials
|
- name: Check for signing credentials
|
||||||
id: creds
|
id: creds
|
||||||
env:
|
env:
|
||||||
@@ -69,14 +66,69 @@ jobs:
|
|||||||
ASC_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
|
ASC_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
|
||||||
TEAM: ${{ secrets.APPLE_TEAM_ID }}
|
TEAM: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
||||||
|
NSE_PROFILE: ${{ secrets.IOS_NSE_PROVISIONING_PROFILE_BASE64 }}
|
||||||
run: |
|
run: |
|
||||||
if [ -n "$CERT" ] && [ -n "$ASC_KEY" ] && [ -n "$TEAM" ] && [ -n "$PROFILE" ]; then
|
if [ -n "$CERT" ] && [ -n "$ASC_KEY" ] && [ -n "$TEAM" ] && [ -n "$PROFILE" ]; then
|
||||||
|
if [ -z "$NSE_PROFILE" ]; then
|
||||||
|
# The notification service extension (plugins/ios-push) is its
|
||||||
|
# own App ID and needs its own App Store profile.
|
||||||
|
echo "::error::IOS_NSE_PROVISIONING_PROFILE_BASE64 is not set: the notification extension (org.bulwarkmail.mobile.NotificationService) can't be signed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
echo "signed=true" >> "$GITHUB_OUTPUT"
|
echo "signed=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "signed=false" >> "$GITHUB_OUTPUT"
|
echo "signed=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "::notice::Signing secrets are not configured - compiling without signing. No IPA will be produced."
|
echo "::notice::Signing secrets are not configured - compiling without signing. No IPA will be produced."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Install the provisioning profiles
|
||||||
|
if: steps.creds.outputs.signed == 'true'
|
||||||
|
id: profiles
|
||||||
|
env:
|
||||||
|
APP_PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
||||||
|
NSE_PROFILE_BASE64: ${{ secrets.IOS_NSE_PROVISIONING_PROFILE_BASE64 }}
|
||||||
|
run: |
|
||||||
|
# Xcode moved the profile directory in 16; write both so this does not
|
||||||
|
# silently stop working on a runner image bump.
|
||||||
|
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||||
|
mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles
|
||||||
|
|
||||||
|
# One App Store profile per target: the app and its notification
|
||||||
|
# service extension (plugins/ios-push).
|
||||||
|
for side in app nse; do
|
||||||
|
if [ "$side" = app ]; then DATA="$APP_PROFILE_BASE64"; else DATA="$NSE_PROFILE_BASE64"; fi
|
||||||
|
PROFILE="$RUNNER_TEMP/$side.mobileprovision"
|
||||||
|
echo "$DATA" | base64 -d > "$PROFILE"
|
||||||
|
|
||||||
|
# A .mobileprovision is a CMS-signed plist, so it has to be decoded
|
||||||
|
# before anything inside it can be read.
|
||||||
|
PLIST=$(security cms -D -i "$PROFILE")
|
||||||
|
UUID=$(echo "$PLIST" | plutil -extract UUID raw -)
|
||||||
|
NAME=$(echo "$PLIST" | plutil -extract Name raw -)
|
||||||
|
# application-identifier is "TEAMID.bundle.id" - deriving the bundle
|
||||||
|
# ID from the profile keeps ExportOptions honest if app.config.js
|
||||||
|
# changes.
|
||||||
|
APP_ID=$(echo "$PLIST" | plutil -extract Entitlements.application-identifier raw -)
|
||||||
|
BUNDLE_ID="${APP_ID#*.}"
|
||||||
|
EXPIRY=$(echo "$PLIST" | plutil -extract ExpirationDate raw -)
|
||||||
|
|
||||||
|
cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision
|
||||||
|
cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision
|
||||||
|
|
||||||
|
echo "${side}_uuid=$UUID" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "${side}_name=$NAME" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "${side}_bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "$side: profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Generate the iOS project
|
||||||
|
env:
|
||||||
|
# plugins/ios-push names each target's profile: the command line
|
||||||
|
# can't, it would give every target the same one.
|
||||||
|
IOS_APP_PROFILE: ${{ steps.profiles.outputs.app_uuid }}
|
||||||
|
IOS_NSE_PROFILE: ${{ steps.profiles.outputs.nse_uuid }}
|
||||||
|
run: npx expo prebuild --platform ios --clean
|
||||||
|
|
||||||
- name: Resolve workspace and scheme
|
- name: Resolve workspace and scheme
|
||||||
id: xcode
|
id: xcode
|
||||||
run: |
|
run: |
|
||||||
@@ -141,38 +193,6 @@ jobs:
|
|||||||
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||||
chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||||
|
|
||||||
- name: Install the provisioning profile
|
|
||||||
if: steps.creds.outputs.signed == 'true'
|
|
||||||
id: profile
|
|
||||||
env:
|
|
||||||
PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
|
||||||
run: |
|
|
||||||
PROFILE="$RUNNER_TEMP/profile.mobileprovision"
|
|
||||||
echo "$PROFILE_BASE64" | base64 -d > "$PROFILE"
|
|
||||||
|
|
||||||
# A .mobileprovision is a CMS-signed plist, so it has to be decoded
|
|
||||||
# before anything inside it can be read.
|
|
||||||
PLIST=$(security cms -D -i "$PROFILE")
|
|
||||||
UUID=$(echo "$PLIST" | plutil -extract UUID raw -)
|
|
||||||
NAME=$(echo "$PLIST" | plutil -extract Name raw -)
|
|
||||||
# application-identifier is "TEAMID.bundle.id" - deriving the bundle ID
|
|
||||||
# from the profile keeps ExportOptions honest if app.config.js changes.
|
|
||||||
APP_ID=$(echo "$PLIST" | plutil -extract Entitlements.application-identifier raw -)
|
|
||||||
BUNDLE_ID="${APP_ID#*.}"
|
|
||||||
EXPIRY=$(echo "$PLIST" | plutil -extract ExpirationDate raw -)
|
|
||||||
|
|
||||||
# Xcode moved the profile directory in 16; write both so this does not
|
|
||||||
# silently stop working on a runner image bump.
|
|
||||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
|
||||||
mkdir -p ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles
|
|
||||||
cp "$PROFILE" ~/Library/MobileDevice/Provisioning\ Profiles/"$UUID".mobileprovision
|
|
||||||
cp "$PROFILE" ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/"$UUID".mobileprovision
|
|
||||||
|
|
||||||
echo "uuid=$UUID" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "name=$NAME" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "bundle_id=$BUNDLE_ID" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Profile '$NAME' ($UUID) for $BUNDLE_ID, expires $EXPIRY"
|
|
||||||
|
|
||||||
- name: Archive
|
- name: Archive
|
||||||
if: steps.creds.outputs.signed == 'true'
|
if: steps.creds.outputs.signed == 'true'
|
||||||
env:
|
env:
|
||||||
@@ -192,8 +212,7 @@ jobs:
|
|||||||
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
|
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
|
||||||
CODE_SIGN_STYLE=Manual \
|
CODE_SIGN_STYLE=Manual \
|
||||||
DEVELOPMENT_TEAM="$TEAM_ID" \
|
DEVELOPMENT_TEAM="$TEAM_ID" \
|
||||||
CODE_SIGN_IDENTITY="Apple Distribution" \
|
CODE_SIGN_IDENTITY="Apple Distribution"
|
||||||
PROVISIONING_PROFILE_SPECIFIER="${{ steps.profile.outputs.uuid }}"
|
|
||||||
|
|
||||||
# Signing failures report a symptom ("no profiles found") rather than the
|
# Signing failures report a symptom ("no profiles found") rather than the
|
||||||
# setting that caused it, and a re-run costs ~30 minutes. Dump the
|
# setting that caused it, and a re-run costs ~30 minutes. Dump the
|
||||||
@@ -215,8 +234,10 @@ jobs:
|
|||||||
id: export
|
id: export
|
||||||
env:
|
env:
|
||||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
PROFILE_NAME: ${{ steps.profile.outputs.name }}
|
PROFILE_NAME: ${{ steps.profiles.outputs.app_name }}
|
||||||
BUNDLE_ID: ${{ steps.profile.outputs.bundle_id }}
|
BUNDLE_ID: ${{ steps.profiles.outputs.app_bundle_id }}
|
||||||
|
NSE_PROFILE_NAME: ${{ steps.profiles.outputs.nse_name }}
|
||||||
|
NSE_BUNDLE_ID: ${{ steps.profiles.outputs.nse_bundle_id }}
|
||||||
run: |
|
run: |
|
||||||
cat > "$RUNNER_TEMP/ExportOptions.plist" <<PLIST
|
cat > "$RUNNER_TEMP/ExportOptions.plist" <<PLIST
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
@@ -230,6 +251,7 @@ jobs:
|
|||||||
<key>provisioningProfiles</key>
|
<key>provisioningProfiles</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>${BUNDLE_ID}</key><string>${PROFILE_NAME}</string>
|
<key>${BUNDLE_ID}</key><string>${PROFILE_NAME}</string>
|
||||||
|
<key>${NSE_BUNDLE_ID}</key><string>${NSE_PROFILE_NAME}</string>
|
||||||
</dict>
|
</dict>
|
||||||
<key>uploadSymbols</key><true/>
|
<key>uploadSymbols</key><true/>
|
||||||
<key>destination</key><string>export</string>
|
<key>destination</key><string>export</string>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Mailbox list and folders: the drawer (or docked sidebar) lists the role
|
||||||
|
# folders and custom ones; opening a folder shows its messages.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Folders
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- takeScreenshot: folders-01-inbox
|
||||||
|
- assertVisible: ".*Your verification code.*"
|
||||||
|
- runFlow: ../subflows/open-drawer.yaml
|
||||||
|
- takeScreenshot: folders-02-drawer
|
||||||
|
- assertVisible: "Inbox([ ,].*)?"
|
||||||
|
- assertVisible: "Sent([ ,].*)?"
|
||||||
|
- assertVisible: "Drafts([ ,].*)?"
|
||||||
|
- assertVisible: "Archive([ ,].*)?"
|
||||||
|
# Parent folders read "Collapse, Projects" (the chevron comes first).
|
||||||
|
- assertVisible: ".*Projects.*"
|
||||||
|
- tapOn: "Archive([ ,].*)?"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Invoice 2025-12.*"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: folders-03-archive
|
||||||
|
- runFlow: ../subflows/open-drawer.yaml
|
||||||
|
# Projects/Alpha holds the seeded thread.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible: "Alpha([ ,].*)?"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Expand, Projects.*"
|
||||||
|
- tapOn: "Alpha([ ,].*)?"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Project Alpha kickoff.*"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: folders-04-alpha
|
||||||
|
- runFlow: ../subflows/go-to-inbox.yaml
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Opening messages: plain text, HTML with blocked remote content, a thread,
|
||||||
|
# and a PDF attachment in the in-app preview.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Read messages
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
|
||||||
|
# Plain text
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/open-message.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: Grüße aus München
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Jürgen.*"
|
||||||
|
timeout: 10000
|
||||||
|
- takeScreenshot: read-01-plain
|
||||||
|
- runFlow: ../subflows/close-message.yaml
|
||||||
|
|
||||||
|
# HTML newsletter
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/open-message.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: Weekly digest
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*5 new articles.*|Weekly digest"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: read-02-html
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Load images"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Load images"
|
||||||
|
- takeScreenshot: read-03-html-images
|
||||||
|
- runFlow: ../subflows/close-message.yaml
|
||||||
|
|
||||||
|
# Attachment and its preview
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/open-message.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: Q3 report
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*q3-report.pdf.*"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: read-04-attachment
|
||||||
|
# The message's attachment chip, labelled with the file name alone (on the
|
||||||
|
# iPad the list row next to it mentions the name in its longer label).
|
||||||
|
- tapOn: "q3-report.pdf"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Share"
|
||||||
|
timeout: 30000
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: read-05-attachment-preview
|
||||||
|
- assertVisible: "q3-report.pdf"
|
||||||
|
- tapOn: "Close"
|
||||||
|
# Back on the message (the iPad pane has a quick reply instead of Reply All).
|
||||||
|
- extendedWaitUntil:
|
||||||
|
notVisible: "Share"
|
||||||
|
timeout: 10000
|
||||||
|
- assertVisible: ".*q3-report.pdf.*"
|
||||||
|
- runFlow: ../subflows/close-message.yaml
|
||||||
|
|
||||||
|
# A thread (Projects/Alpha)
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/go-to-folder.yaml
|
||||||
|
env:
|
||||||
|
FOLDER: Alpha
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/open-message.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: Project Alpha kickoff
|
||||||
|
- takeScreenshot: read-06-thread
|
||||||
|
- runFlow: ../subflows/go-to-inbox.yaml
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Compose a new message and send it to userb; userb's mailbox (checked over
|
||||||
|
# JMAP) must get it.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Compose and send
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- evalScript: ${output.subject = 'E2E compose ' + DEVICE + '-' + APPEARANCE + '-' + ORIENTATION + ' ' + Date.now()}
|
||||||
|
- tapOn: "Compose"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "New Message"
|
||||||
|
timeout: 15000
|
||||||
|
- tapOn: "Recipient email addresses"
|
||||||
|
- inputText: "userb@example.org"
|
||||||
|
- pressKey: Enter
|
||||||
|
- tapOn:
|
||||||
|
text: "Subject"
|
||||||
|
index: 1
|
||||||
|
- inputText: ${output.subject}
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/compose-body.yaml
|
||||||
|
env:
|
||||||
|
FROM_SUBJECT: "yes"
|
||||||
|
BODY: "Hello from the iOS simulator test."
|
||||||
|
- takeScreenshot: compose-01-filled
|
||||||
|
|
||||||
|
# The attach sheet offers its options one by one (it used to be a single
|
||||||
|
# accessibility element, and picking one opened no picker on iOS).
|
||||||
|
- tapOn: "Attach"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Photos & Videos"
|
||||||
|
timeout: 10000
|
||||||
|
- assertVisible: "Camera"
|
||||||
|
# A server with Files offers "Upload from this device" and "Attach from
|
||||||
|
# Files" instead of a plain "Files".
|
||||||
|
- assertVisible: "Upload from this device|Files"
|
||||||
|
- takeScreenshot: compose-02-attach-sheet
|
||||||
|
- evalScript: ${output.pickPhoto = ORIENTATION != 'landscape'}
|
||||||
|
# The system photo picker runs in another process: XCTest sees none of its
|
||||||
|
# elements, but taps by position reach it. Pick the first photo (the app
|
||||||
|
# icon, added by boot-sim.sh) and confirm with the check mark. The iPad in
|
||||||
|
# landscape lays the picker out differently, so it only closes the sheet.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${!output.pickPhoto}
|
||||||
|
commands:
|
||||||
|
- tapOn: "Cancel"
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${output.pickPhoto}
|
||||||
|
commands:
|
||||||
|
- tapOn: "Photos & Videos"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
notVisible: "Draft saved|New Message"
|
||||||
|
timeout: 10000
|
||||||
|
optional: true
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: compose-03-photo-picker
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${DEVICE == 'tablet'}
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
point: "44%,33%"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 2000
|
||||||
|
- tapOn:
|
||||||
|
point: "90%,26%"
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${DEVICE != 'tablet'}
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
point: "16%,25%"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 2000
|
||||||
|
- tapOn:
|
||||||
|
point: "90%,13%"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "New Message"
|
||||||
|
timeout: 20000
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: compose-04-with-photo
|
||||||
|
|
||||||
|
- tapOn: "Send"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Email sent successfully"
|
||||||
|
timeout: 20000
|
||||||
|
optional: true
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/assert-delivered.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: ${output.subject}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${output.pickPhoto}
|
||||||
|
commands:
|
||||||
|
- assertTrue:
|
||||||
|
condition: ${output.helper.attachments.length >= 1}
|
||||||
|
label: "${output.subject} carries the picked photo"
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Incoming mail while the app is open (sent by userb over JMAP), then reply
|
||||||
|
# and forward it; userb receives both, the forward with the attachment.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Incoming, reply, forward
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- evalScript: ${output.incoming = 'E2E incoming ' + DEVICE + '-' + APPEARANCE + '-' + ORIENTATION + ' ' + Date.now()}
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /send?to=${ACCOUNT}&subject=${encodeURIComponent(output.incoming)}&attach=1&html=1
|
||||||
|
# Arrives without a pull to refresh (push over EventSource).
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*${output.incoming}.*"
|
||||||
|
timeout: 90000
|
||||||
|
- takeScreenshot: incoming-01-arrived
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/open-message.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: ${output.incoming}
|
||||||
|
- takeScreenshot: incoming-02-open
|
||||||
|
|
||||||
|
# Reply
|
||||||
|
- tapOn:
|
||||||
|
text: "Reply"
|
||||||
|
index: 0
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Re: ${output.incoming}"
|
||||||
|
timeout: 15000
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/compose-body.yaml
|
||||||
|
env:
|
||||||
|
BODY: "Reply from the iOS simulator test."
|
||||||
|
- takeScreenshot: incoming-03-reply
|
||||||
|
- tapOn: "Send"
|
||||||
|
# The toast is gone after a few seconds; delivery is checked below.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Email sent successfully"
|
||||||
|
timeout: 20000
|
||||||
|
optional: true
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/assert-delivered.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: "Re: ${output.incoming}"
|
||||||
|
|
||||||
|
# Forward with the attachment
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Forward"
|
||||||
|
timeout: 15000
|
||||||
|
- tapOn:
|
||||||
|
text: "Forward"
|
||||||
|
index: 0
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Fwd: ${output.incoming}"
|
||||||
|
timeout: 15000
|
||||||
|
- tapOn: "Recipient email addresses"
|
||||||
|
- inputText: "userb@example.org"
|
||||||
|
- pressKey: Enter
|
||||||
|
- takeScreenshot: incoming-04-forward
|
||||||
|
- tapOn: "Send"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Email sent successfully"
|
||||||
|
timeout: 20000
|
||||||
|
optional: true
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/assert-delivered.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: "Fwd: ${output.incoming}"
|
||||||
|
ATTACHMENT: e2e-attachment.pdf
|
||||||
|
- runFlow: ../subflows/close-message.yaml
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Search: a hit, a miss, clearing.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Search
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- tapOn: "Search mail..."
|
||||||
|
- inputText: "verification"
|
||||||
|
- pressKey: Enter
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Your verification code.*"
|
||||||
|
timeout: 30000
|
||||||
|
- assertNotVisible: ".*Weekly digest.*"
|
||||||
|
- takeScreenshot: search-01-hit
|
||||||
|
- tapOn: "Clear search"
|
||||||
|
- tapOn: "Search mail..."
|
||||||
|
- inputText: "zzqx-no-such-mail"
|
||||||
|
- pressKey: Enter
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "No results found"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: search-02-miss
|
||||||
|
- tapOn: "Clear"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Weekly digest.*"
|
||||||
|
timeout: 30000
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Swipe actions with undo: swipe left archives (undo brings it back), swipe
|
||||||
|
# right toggles read.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Swipe actions and undo
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: ".*Grüße aus München.*"
|
||||||
|
direction: DOWN
|
||||||
|
centerElement: true
|
||||||
|
timeout: 20000
|
||||||
|
# Swipe the row itself: the avatar checkbox's label has the subject too, and
|
||||||
|
# a swipe that starts on it only selects the row (#91).
|
||||||
|
- swipe:
|
||||||
|
from:
|
||||||
|
id: email-row
|
||||||
|
text: ".*Grüße aus München.*"
|
||||||
|
direction: LEFT
|
||||||
|
duration: 400
|
||||||
|
# The undo bar shows for 5 s; when it goes, the Compose button slides into
|
||||||
|
# its place, and a late tap on UNDO opens the composer. So tap at once and
|
||||||
|
# check the rest afterwards.
|
||||||
|
- tapOn:
|
||||||
|
text: "UNDO"
|
||||||
|
waitToSettleTimeoutMs: 500
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Grüße aus München.*"
|
||||||
|
timeout: 15000
|
||||||
|
- takeScreenshot: swipe-02-undone
|
||||||
|
|
||||||
|
- swipe:
|
||||||
|
from:
|
||||||
|
id: email-row
|
||||||
|
text: ".*Grüße aus München.*"
|
||||||
|
direction: RIGHT
|
||||||
|
duration: 400
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- takeScreenshot: swipe-03-read-toggled
|
||||||
|
- swipe:
|
||||||
|
from:
|
||||||
|
id: email-row
|
||||||
|
text: ".*Grüße aus München.*"
|
||||||
|
direction: RIGHT
|
||||||
|
duration: 400
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Message actions with undo: move to a folder, delete to Trash, select and
|
||||||
|
# star from the list.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Move, delete, select
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
|
||||||
|
# Move via More → Move to
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/open-message.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: Weekly digest
|
||||||
|
- tapOn: "more"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "More actions"
|
||||||
|
timeout: 10000
|
||||||
|
- takeScreenshot: actions-01-more
|
||||||
|
# Both the menu row and the sheet title read "Move to" (webmail catalog),
|
||||||
|
# so wait for the folder list instead of the title.
|
||||||
|
- tapOn: "Move to"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Projects"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn:
|
||||||
|
text: "Projects"
|
||||||
|
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
|
||||||
|
waitToSettleTimeoutMs: 500
|
||||||
|
- takeScreenshot: actions-02-moved
|
||||||
|
# Tap UNDO at once (see 06-swipe-undo: a late tap opens the composer).
|
||||||
|
- tapOn:
|
||||||
|
text: "UNDO"
|
||||||
|
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
|
||||||
|
waitToSettleTimeoutMs: 500
|
||||||
|
- runFlow: ../subflows/close-message.yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Weekly digest.*"
|
||||||
|
timeout: 15000
|
||||||
|
|
||||||
|
# Delete (to Trash) and undo
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/open-message.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: Weekly digest
|
||||||
|
- takeScreenshot: actions-03a-before-delete
|
||||||
|
- tapOn:
|
||||||
|
text: "Delete"
|
||||||
|
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
|
||||||
|
waitToSettleTimeoutMs: 500
|
||||||
|
- takeScreenshot: actions-03-deleted
|
||||||
|
- tapOn:
|
||||||
|
text: "UNDO"
|
||||||
|
# The undo bar shows for 5 s: don't spend them waiting for the screen to settle.
|
||||||
|
waitToSettleTimeoutMs: 500
|
||||||
|
- runFlow: ../subflows/close-message.yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Weekly digest.*"
|
||||||
|
timeout: 15000
|
||||||
|
|
||||||
|
# Select from the list and star, then unstar. On a phone the row can be
|
||||||
|
# below the fold (earlier runs left mail on top).
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: ".*Grüße aus München.*"
|
||||||
|
direction: DOWN
|
||||||
|
centerElement: true
|
||||||
|
timeout: 20000
|
||||||
|
# The row, not its avatar checkbox (whose label has the subject too).
|
||||||
|
- longPressOn:
|
||||||
|
id: email-row
|
||||||
|
text: ".*Grüße aus München.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Clear selection"
|
||||||
|
timeout: 10000
|
||||||
|
- takeScreenshot: actions-04-selected
|
||||||
|
# Star (or Unstar, when an earlier run left it starred), then toggle back.
|
||||||
|
# On a tablet the list column is narrow and Star sits in the More menu.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible: "Star|Unstar"
|
||||||
|
commands:
|
||||||
|
- tapOn: "More actions.*"
|
||||||
|
- tapOn: "Star|Unstar"
|
||||||
|
# A bulk action ends selection mode by itself.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Clear selection"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Clear selection"
|
||||||
|
- longPressOn:
|
||||||
|
id: email-row
|
||||||
|
text: ".*Grüße aus München.*"
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible: "Unstar|Star"
|
||||||
|
commands:
|
||||||
|
- tapOn: "More actions.*"
|
||||||
|
- tapOn: "Unstar|Star"
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Clear selection"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Clear selection"
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Scheduled send: schedule a message for later, find it under Scheduled,
|
||||||
|
# cancel it.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Scheduled send
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- evalScript: ${output.subject = 'E2E scheduled ' + DEVICE + '-' + APPEARANCE + '-' + ORIENTATION + ' ' + Date.now()}
|
||||||
|
- tapOn: "Compose"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "New Message"
|
||||||
|
timeout: 15000
|
||||||
|
- tapOn: "Recipient email addresses"
|
||||||
|
- inputText: "userb@example.org"
|
||||||
|
- pressKey: Enter
|
||||||
|
- tapOn:
|
||||||
|
text: "Subject"
|
||||||
|
index: 1
|
||||||
|
- inputText: ${output.subject}
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/compose-body.yaml
|
||||||
|
env:
|
||||||
|
FROM_SUBJECT: "yes"
|
||||||
|
BODY: "Scheduled from the iOS simulator test."
|
||||||
|
- tapOn: "Schedule send"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Tomorrow morning.*"
|
||||||
|
timeout: 10000
|
||||||
|
- takeScreenshot: scheduled-01-options
|
||||||
|
- tapOn: "Tomorrow morning.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Scheduled"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: scheduled-02-confirmed
|
||||||
|
- tapOn: "OK"
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/go-to-folder.yaml
|
||||||
|
env:
|
||||||
|
FOLDER: Scheduled
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*${output.subject}.*"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: scheduled-03-list
|
||||||
|
- tapOn: ".*${output.subject}.*"
|
||||||
|
- tapOn: "Cancel send"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Keep scheduled"
|
||||||
|
timeout: 10000
|
||||||
|
# The dialog's button (below its message), not its title or the button
|
||||||
|
# behind it. Cancelling now moves the message back to Drafts.
|
||||||
|
- tapOn:
|
||||||
|
text: "Cancel send"
|
||||||
|
below: ".*will not be (sent|delivered).*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
notVisible: ".*${output.subject}.*"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: scheduled-04-cancelled
|
||||||
|
- runFlow: ../subflows/go-to-inbox.yaml
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Calendar: the views, create an event with a reminder, edit it, delete it.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Calendar
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- evalScript: ${output.event = 'E2E event ' + DEVICE + ' ' + Date.now() % 100000}
|
||||||
|
- tapOn:
|
||||||
|
id: tab-calendar
|
||||||
|
# The first visit asks for notification permission (event reminders).
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Would Like to Send You Notifications.*|Go to today"
|
||||||
|
timeout: 30000
|
||||||
|
- runFlow: ../subflows/allow-notifications.yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Go to today"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: calendar-01-month
|
||||||
|
|
||||||
|
- tapOn: "Open menu"
|
||||||
|
- tapOn: "Schedule"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: calendar-02-schedule
|
||||||
|
- tapOn: "Open menu"
|
||||||
|
- tapOn: "Day"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: calendar-03-day
|
||||||
|
- tapOn: "Open menu"
|
||||||
|
- tapOn: "Week"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: calendar-04-week
|
||||||
|
|
||||||
|
# Create. The button starts the event on the selected day: make that today
|
||||||
|
# (a stray tap, such as one through the notification prompt, can select
|
||||||
|
# another day), so the schedule below finds it after Go to today.
|
||||||
|
- tapOn: "Go to today"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- tapOn: "New event"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Save"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn: "Title"
|
||||||
|
- inputText: ${output.event}
|
||||||
|
# Close the keyboard first: a tap while it slides away lands on a form that
|
||||||
|
# is still moving.
|
||||||
|
- pressKey: Enter
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
# A new event starts on the calendar's default reminders; Customize turns
|
||||||
|
# them into the event's own list, which has Add reminder.
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: "Customize|Add reminder"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 10000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Customize"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Customize"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: "Add reminder"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 10000
|
||||||
|
- repeat:
|
||||||
|
while:
|
||||||
|
notVisible: "15 minutes before"
|
||||||
|
times: 2
|
||||||
|
commands:
|
||||||
|
- tapOn: "Add reminder"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: "15 minutes before"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn: "15 minutes before"
|
||||||
|
- takeScreenshot: calendar-05-editor
|
||||||
|
- tapOn: "Save"
|
||||||
|
# The editor closes (its Save goes; the FAB is labelled "New event" too).
|
||||||
|
- extendedWaitUntil:
|
||||||
|
notVisible: "Save"
|
||||||
|
timeout: 20000
|
||||||
|
|
||||||
|
# Find it in the schedule and open it
|
||||||
|
- tapOn: "Open menu"
|
||||||
|
- tapOn: "Schedule"
|
||||||
|
- tapOn: "Go to today"
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: ".*${output.event}.*"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 30000
|
||||||
|
- tapOn: ".*${output.event}.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Edit"
|
||||||
|
timeout: 10000
|
||||||
|
- takeScreenshot: calendar-06-detail
|
||||||
|
- assertVisible: ".*15 minutes before.*"
|
||||||
|
|
||||||
|
# Edit: add a location (an empty field, so no cursor games in the title)
|
||||||
|
- tapOn: "Edit"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Edit event"
|
||||||
|
timeout: 10000
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: "Place or address"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn: "Place or address"
|
||||||
|
- inputText: "Room 42"
|
||||||
|
- tapOn: "Save"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Room 42.*"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: calendar-07-edited
|
||||||
|
|
||||||
|
# Delete (the detail sheet is still open after saving)
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible: "Delete"
|
||||||
|
commands:
|
||||||
|
- tapOn: ".*${output.event}.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Delete"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn: "Delete"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Delete this event.*"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn:
|
||||||
|
text: "Delete"
|
||||||
|
below: "Delete this event.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
notVisible: ".*${output.event}.*"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: calendar-08-deleted
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Contacts: the list, a contact's detail, create one and delete it again.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Contacts
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- evalScript: ${output.last = 'Tester' + Date.now() % 100000}
|
||||||
|
- tapOn:
|
||||||
|
id: tab-contacts
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "All Contacts"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: contacts-01-list
|
||||||
|
- tapOn: ".*Alice.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Email"
|
||||||
|
timeout: 10000
|
||||||
|
- takeScreenshot: contacts-02-detail
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Back"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Back"
|
||||||
|
|
||||||
|
- tapOn: "New Contact"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Save"
|
||||||
|
timeout: 10000
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/type.yaml
|
||||||
|
env:
|
||||||
|
FIELD: contact-first-name
|
||||||
|
TEXT: E2E
|
||||||
|
# Return moves to Middle name, then Last name: tapping the field would hit
|
||||||
|
# the keyboard covering it.
|
||||||
|
- pressKey: Enter
|
||||||
|
- pressKey: Enter
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/type.yaml
|
||||||
|
env:
|
||||||
|
FIELD: contact-last-name
|
||||||
|
TAP: "no"
|
||||||
|
TEXT: ${output.last}
|
||||||
|
- takeScreenshot: contacts-03-form
|
||||||
|
- tapOn: "Save"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*${output.last}.*"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: contacts-04-created
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible: "More actions"
|
||||||
|
commands:
|
||||||
|
- tapOn: ".*E2E ${output.last}.*"
|
||||||
|
- tapOn: "More actions"
|
||||||
|
- tapOn: "Delete"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Delete contact"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn:
|
||||||
|
text: "Delete"
|
||||||
|
below: "Are you sure.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
notVisible: ".*E2E ${output.last}.*"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: contacts-05-deleted
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Files: open a folder, preview a text and a PDF file, create and delete a
|
||||||
|
# folder.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Files
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- evalScript: ${output.folder = 'E2E folder ' + Date.now() % 100000}
|
||||||
|
- tapOn:
|
||||||
|
id: tab-files
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Documents.*"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: files-01-root
|
||||||
|
- tapOn: ".*Documents.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*readme.txt.*"
|
||||||
|
timeout: 20000
|
||||||
|
- tapOn: ".*readme.txt.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "readme.txt"
|
||||||
|
timeout: 20000
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: files-02-text-preview
|
||||||
|
- tapOn: "Close"
|
||||||
|
- tapOn: ".*q3-report.pdf.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "q3-report.pdf"
|
||||||
|
timeout: 20000
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: files-03-pdf-preview
|
||||||
|
- tapOn: "Close"
|
||||||
|
- tapOn: "Back"
|
||||||
|
|
||||||
|
- tapOn: "New Folder"
|
||||||
|
- tapOn: "Folder name"
|
||||||
|
- inputText: ${output.folder}
|
||||||
|
- tapOn: "Create"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*${output.folder}.*"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: files-04-folder-created
|
||||||
|
- longPressOn: ".*${output.folder}.*"
|
||||||
|
# Delete moves it to the trash at once, with Undo and no question (like the
|
||||||
|
# webmail). A tap while the actions sheet still slides in can miss: tap
|
||||||
|
# again while Delete is still there.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Delete"
|
||||||
|
timeout: 10000
|
||||||
|
- repeat:
|
||||||
|
while:
|
||||||
|
visible: "Delete"
|
||||||
|
times: 3
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
text: "Delete"
|
||||||
|
optional: true
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- extendedWaitUntil:
|
||||||
|
notVisible: ".*${output.folder}.*"
|
||||||
|
timeout: 20000
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Every settings page opens and shows its content, first half (13-settings-more
|
||||||
|
# has the rest: one flow for all 24 pages ran into the 15-minute flow limit on
|
||||||
|
# the iPad simulator).
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Settings pages
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- tapOn:
|
||||||
|
id: tab-settings
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Search settings"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: settings-00-list
|
||||||
|
# The search also lists the settings inside each page under it, so a common
|
||||||
|
# word ("Folders", "Calendar") pushes the page's row below the keyboard.
|
||||||
|
# Each Q matches its page near the top. A PROOF the search lists under the
|
||||||
|
# row would pass on the iPad without the page, so those differ. The Themes
|
||||||
|
# cards are one element each, named by the theme: check the page's text.
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: account, ROW: ".*@example.org, .*", PROOF: "Logged-in accounts|Display Name"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: language, ROW: "Language, Region & Time, .*", Q: "Language", PROOF: "Date Format"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: notifications, ROW: "Notifications, .*", Q: "Notifications", PROOF: "Email Notifications"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: appearance, ROW: "Appearance, .*", Q: "Appearance", PROOF: "Choose your preferred color scheme"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: layout, ROW: "Layout, .*", Q: "Layout", PROOF: "Tune the email list interactions for mobile."}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: reading, ROW: "Reading, .*", Q: "Reading", PROOF: "Mark as Read"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: composing, ROW: "Composing, .*", Q: "Composing", PROOF: "Composer"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: identities, ROW: "Identities, .*", Q: "Identities", PROOF: "Sending Identities"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: vacation, ROW: "Out of Office, .*", Q: "Office", PROOF: "Reply automatically"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: filters, ROW: "Filters, .*", Q: "Filters", PROOF: "Email Filters"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: templates, ROW: "Templates, .*", Q: "Templates", PROOF: "Email Templates"}}
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Every settings page opens and shows its content, second half (see 12-settings).
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Settings pages, continued
|
||||||
|
tags:
|
||||||
|
- features
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
- tapOn:
|
||||||
|
id: tab-settings
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Search settings"
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: settings-00-list-more
|
||||||
|
# The search also lists the settings inside each page under it, so a common
|
||||||
|
# word ("Folders", "Calendar") pushes the page's row below the keyboard.
|
||||||
|
# Each Q matches its page near the top. A PROOF the search lists under the
|
||||||
|
# row would pass on the iPad without the page, so those differ. The Themes
|
||||||
|
# cards are one element each, named by the theme: check the page's text.
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: folders, ROW: "Folders, .*", Q: "New folder", PROOF: ".*Inbox.*"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: tags, ROW: "Tags, .*", Q: "Tags", PROOF: "Email Tags"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: downloads, ROW: "Downloads, .*", Q: "Downloads", PROOF: "Email .*eml.* filename"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: security, ROW: "Security, .*", Q: "Security", PROOF: "Account Security|Detecting server capabilities.*"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: content, ROW: "Content & Senders, .*", Q: "Content &", PROOF: "External Content"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: calendar, ROW: "Calendar, .*", Q: "Week start", PROOF: "Default view"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: contacts, ROW: "Contacts, .*", Q: "Group by", PROOF: "Sort by last name"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: files, ROW: "Files, .*", Q: "Files", PROOF: "Default View"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: sidebar-apps, ROW: "Sidebar Apps, .*", Q: "Sidebar", PROOF: "Keep Apps Loaded"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: themes, ROW: "Themes, .*", Q: "Themes", PROOF: "Pick a colou?r theme.*"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: storage, ROW: "Storage & backup, .*", Q: "Storage", PROOF: "Offline mail.*"}}
|
||||||
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: about, ROW: "About, .*", Q: "About", PROOF: "This device"}}
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Fresh install: the app starts and shows the sign-in choices, and typing
|
||||||
|
# into a field works. Needs no server, so it runs before every suite as the
|
||||||
|
# warm-up for the XCTest driver. It types only a made-up address, so its
|
||||||
|
# debug log is safe to print.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Launch to sign-in
|
||||||
|
tags:
|
||||||
|
- smoke
|
||||||
|
---
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
clearKeychain: true
|
||||||
|
# A cold start of a release build on a CI simulator can be slow.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Enter server details manually"
|
||||||
|
timeout: 120000
|
||||||
|
- takeScreenshot: warmup-01-sign-in-choices
|
||||||
|
- tapOn: "Enter server details manually"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: login-server-input
|
||||||
|
timeout: 10000
|
||||||
|
- runFlow: subflows/dismiss-keyboard-tip.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: subflows/type.yaml
|
||||||
|
env:
|
||||||
|
FIELD: login-server-input
|
||||||
|
TAP: "no"
|
||||||
|
TEXT: typing-probe.example.net
|
||||||
|
- takeScreenshot: warmup-02-typed
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Perf: open a message and go back, five times. The app is already running
|
||||||
|
# with -perfMarks YES (no launchApp here: it would drop the launch argument).
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Perf open message
|
||||||
|
---
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: email-row
|
||||||
|
timeout: 60000
|
||||||
|
- repeat:
|
||||||
|
times: 5
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
id: email-row
|
||||||
|
index: 1
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Reply All"
|
||||||
|
timeout: 30000
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Back"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Back"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Perf: switch through the tabs, three times.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Perf tab switches
|
||||||
|
---
|
||||||
|
- repeat:
|
||||||
|
times: 3
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
id: tab-calendar
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: ".*Would Like to Send You Notifications.*"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Allow"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- tapOn:
|
||||||
|
id: tab-contacts
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- takeScreenshot: perf-contacts
|
||||||
|
- tapOn:
|
||||||
|
id: tab-files
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- tapOn:
|
||||||
|
id: tab-settings
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Perf: open the composer and close it untouched, five times.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Perf compose open
|
||||||
|
---
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
|
- repeat:
|
||||||
|
times: 5
|
||||||
|
commands:
|
||||||
|
- tapOn: "Compose"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "New Message"
|
||||||
|
timeout: 20000
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- tapOn: "Close"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Discard"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Discard"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Perf: fling through the long inbox (thousands of messages), then back up.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Perf list scroll
|
||||||
|
---
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: email-row
|
||||||
|
timeout: 30000
|
||||||
|
- repeat:
|
||||||
|
times: 8
|
||||||
|
commands:
|
||||||
|
- swipe:
|
||||||
|
start: 50%,80%
|
||||||
|
end: 50%,20%
|
||||||
|
duration: 250
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 4000
|
||||||
|
- repeat:
|
||||||
|
times: 8
|
||||||
|
commands:
|
||||||
|
- swipe:
|
||||||
|
start: 50%,20%
|
||||||
|
end: 50%,80%
|
||||||
|
duration: 250
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 4000
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Performance runs: sign in once (ACCOUNT, the bulk account with thousands of
|
||||||
|
# messages). Later installs of other builds keep the data and the keychain.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Perf sign-in
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/password-sign-in.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
INBOX_TEXT: ".*Bulk.*"
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Push on iOS: the relay's payload carries only ids and a placeholder text.
|
||||||
|
# A tap opens the message, with the app running and from a cold start. The
|
||||||
|
# helper sends the message as userb and pushes the relay's payload with
|
||||||
|
# simctl, so this needs no APNs key.
|
||||||
|
#
|
||||||
|
# simctl push hands the payload to the simulator as a local notification
|
||||||
|
# request: the notification service extension (plugins/ios-push), which
|
||||||
|
# puts sender and subject in, never runs that way (the log shows no
|
||||||
|
# NotificationService process). So the banner shows the placeholder here;
|
||||||
|
# the extension needs a real APNs push to be tested.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Push notification on iOS
|
||||||
|
tags:
|
||||||
|
- push
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/signed-in.yaml
|
||||||
|
# Without notification permission iOS drops the push silently, and the
|
||||||
|
# launch-time grant doesn't reach it on these simulators: let the app ask
|
||||||
|
# (the Calendar tab does, for event reminders) and allow.
|
||||||
|
- tapOn:
|
||||||
|
id: tab-calendar
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Would Like to Send You Notifications.*|Go to today"
|
||||||
|
timeout: 30000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: ".*Would Like to Send You Notifications.*"
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
text: "Allow"
|
||||||
|
optional: true
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- pressKey: Home
|
||||||
|
|
||||||
|
# 1. The banner shows (and what it says).
|
||||||
|
- evalScript: ${output.subject0 = 'E2E push banner ' + Date.now()}
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(output.subject0)}
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*${output.subject0}.*|.*New message.*"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: push-01-banner
|
||||||
|
# Let it go, so the next tap can't hit this one.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
notVisible: ".*${output.subject0}.*|.*New message.*"
|
||||||
|
timeout: 20000
|
||||||
|
|
||||||
|
# 2. A tap opens the message, app in the background. The banner shows for a
|
||||||
|
# few seconds and finding it takes Maestro about as long; taps on the lock
|
||||||
|
# screen list do nothing on these simulators. So tap where the banner
|
||||||
|
# appears, right after the push (push-and-tap.yaml).
|
||||||
|
- evalScript: ${output.subject = 'E2E push ' + Date.now()}
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/push-and-tap.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: ${output.subject}
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Reply All"
|
||||||
|
timeout: 60000
|
||||||
|
- assertVisible: ".*${output.subject}.*"
|
||||||
|
- takeScreenshot: push-02-opened-warm
|
||||||
|
|
||||||
|
# 3. The same from a cold start: the app isn't running.
|
||||||
|
- stopApp
|
||||||
|
- evalScript: ${output.subject2 = 'E2E push cold ' + Date.now()}
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/push-and-tap.yaml
|
||||||
|
env:
|
||||||
|
SUBJECT: ${output.subject2}
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Reply All"
|
||||||
|
timeout: 90000
|
||||||
|
- assertVisible: ".*${output.subject2}.*"
|
||||||
|
- takeScreenshot: push-04-opened-cold
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Calls the test helper (scripts/e2e/helper.mjs) on the runner.
|
||||||
|
// env CALL: path and query, e.g. "/totp" or "/pair?user=x&deliver=openurl".
|
||||||
|
// The answer lands in output.helper.
|
||||||
|
const response = http.get(HELPER + CALL);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('helper ' + CALL.split('?')[0] + ' failed: ' + response.status + ' ' + response.body);
|
||||||
|
}
|
||||||
|
output.helper = json(response.body);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Password sign-in as the first account, sign out, sign in again.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Password, sign out, sign in again
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- takeScreenshot: password-01-choices
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/manual-server.yaml
|
||||||
|
env:
|
||||||
|
HOST: ${SERVER}
|
||||||
|
- takeScreenshot: password-02-confirm
|
||||||
|
- tapOn: "Sign in with a password instead"
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/password-form.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- takeScreenshot: password-03-inbox
|
||||||
|
|
||||||
|
- runFlow: ../subflows/sign-out.yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Enter server details manually"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: password-04-signed-out
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/password-sign-in.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
- takeScreenshot: password-05-signed-in-again
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# A wrong password and an unreachable server: the app says what went wrong
|
||||||
|
# and stays on the form.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Password errors
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/manual-server.yaml
|
||||||
|
env:
|
||||||
|
HOST: ${SERVER}
|
||||||
|
- tapOn: "Sign in with a password instead"
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/password-form.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
PASSWORD: wrong-password-123
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "That didn't work"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: errors-01-wrong-password
|
||||||
|
- assertVisible:
|
||||||
|
id: login-password-input
|
||||||
|
|
||||||
|
# Back to the server step, then a host that doesn't resolve.
|
||||||
|
- tapOn: "Back"
|
||||||
|
- tapOn: "Back"
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/type.yaml
|
||||||
|
env:
|
||||||
|
FIELD: login-server-input
|
||||||
|
TAP: "no"
|
||||||
|
ERASE: "yes"
|
||||||
|
TEXT: mail.does-not-exist.invalid
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign in with a password instead"
|
||||||
|
timeout: 20000
|
||||||
|
- tapOn: "Sign in with a password instead"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: login-password-input
|
||||||
|
timeout: 10000
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/type-secret.yaml
|
||||||
|
env:
|
||||||
|
FIELD: login-password-input
|
||||||
|
# The email is filled in, so the password field focuses itself.
|
||||||
|
TAP: "no"
|
||||||
|
TEXT: ${PASSWORD}
|
||||||
|
- pressKey: Enter
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Can't reach mail.does-not-exist.invalid|No mail server at mail.does-not-exist.invalid|Sign-in failed"
|
||||||
|
timeout: 60000
|
||||||
|
- takeScreenshot: errors-02-unreachable
|
||||||
|
- assertVisible:
|
||||||
|
id: login-password-input
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# A TOTP account signs in with a password: Stalwart answers 402 for the right
|
||||||
|
# password, and the app asks for the code.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Password with TOTP
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/manual-server.yaml
|
||||||
|
env:
|
||||||
|
HOST: ${SERVER}
|
||||||
|
- tapOn: "Sign in with a password instead"
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/password-form.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${TOTP_ACCOUNT}@example.org
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Authentication code"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: totp-01-code-asked
|
||||||
|
- runFlow: ../subflows/two-factor.yaml
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- takeScreenshot: totp-02-inbox
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# OAuth with Stalwart's own authorization server: the confirm step's
|
||||||
|
# Continue opens an ASWebAuthenticationSession on Stalwart's sign-in page.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: OAuth (Stalwart)
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/manual-server.yaml
|
||||||
|
env:
|
||||||
|
HOST: ${SERVER}
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- runFlow: ../subflows/allow-auth-session.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/stalwart-login-page.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- takeScreenshot: oauth-01-inbox
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Backing out of OAuth: Cancel on the system alert, and closing the sheet
|
||||||
|
# halfway. Both leave the user on the confirm step without an error.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: OAuth cancelled
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/manual-server.yaml
|
||||||
|
env:
|
||||||
|
HOST: ${SERVER}
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Wants to Use.*to Sign In.*"
|
||||||
|
timeout: 30000
|
||||||
|
- tapOn:
|
||||||
|
text: "Cancel"
|
||||||
|
below: ".*Wants to Use.*to Sign In.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign in with a password instead"
|
||||||
|
timeout: 20000
|
||||||
|
- assertNotVisible: "Sign-in failed"
|
||||||
|
- assertNotVisible: "Sign-in was interrupted"
|
||||||
|
- takeScreenshot: oauth-cancel-01-alert-cancelled
|
||||||
|
|
||||||
|
# Accept the alert, then close the sheet on Stalwart's page.
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- runFlow: ../subflows/allow-auth-session.yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "you@example.com"
|
||||||
|
timeout: 60000
|
||||||
|
- tapOn: "Cancel"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign in with a password instead"
|
||||||
|
timeout: 20000
|
||||||
|
- assertNotVisible: "Sign-in failed"
|
||||||
|
- assertNotVisible: "Sign-in was interrupted"
|
||||||
|
- takeScreenshot: oauth-cancel-02-sheet-closed
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Sign in via webmail: the confirm step on a Bulwark webmail address hands
|
||||||
|
# off to the webmail's sign-in page in an auth session.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Webmail hand-off
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/manual-server.yaml
|
||||||
|
env:
|
||||||
|
HOST: ${WEBMAIL}
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- runFlow: ../subflows/allow-auth-session.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/webmail-login-page.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- takeScreenshot: webmail-01-inbox
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Webmail hand-off for a TOTP account: the webmail asks for the code; if it
|
||||||
|
# hands back the password, the app finishes with a code of its own. Uses the
|
||||||
|
# beta webmail: the stable one still has 06's session in the browser, which
|
||||||
|
# strands the hand-off (see 20-webmail-session.yaml).
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Webmail hand-off with TOTP
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/manual-server.yaml
|
||||||
|
env:
|
||||||
|
HOST: ${WEBMAIL_BETA}
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- runFlow: ../subflows/allow-auth-session.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/webmail-login-page.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${TOTP_ACCOUNT}@example.org
|
||||||
|
TOTP: "yes"
|
||||||
|
- runFlow: ../subflows/finish-two-factor-if-asked.yaml
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- takeScreenshot: webmail-totp-01-inbox
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Sign-in link from another app (simctl openurl), password account. The
|
||||||
|
# helper signs in to the test webmail, creates a fresh "Link mobile app" code
|
||||||
|
# and opens its bulwarkmail://pair link on the simulator.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Sign-in link from another app
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /pair?user=${ACCOUNT}&deliver=openurl
|
||||||
|
- runFlow: ../subflows/confirm-link.yaml
|
||||||
|
- runFlow: ../subflows/finish-two-factor-if-asked.yaml
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- takeScreenshot: link-password-01-inbox
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# "Paste sign-in link" with a fresh pairing link on the clipboard (simctl
|
||||||
|
# pbcopy), password account.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Paste sign-in link
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /pair?user=${ACCOUNT}&deliver=pbcopy
|
||||||
|
- runFlow: ../subflows/paste-link.yaml
|
||||||
|
- runFlow: ../subflows/finish-two-factor-if-asked.yaml
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- takeScreenshot: paste-password-01-inbox
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Sign-in link from another app for a TOTP account.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Sign-in link, TOTP account
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /pair?user=${TOTP_ACCOUNT}&deliver=openurl
|
||||||
|
- runFlow: ../subflows/confirm-link.yaml
|
||||||
|
- runFlow: ../subflows/finish-two-factor-if-asked.yaml
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- takeScreenshot: link-totp-01-inbox
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# "Paste sign-in link" for a TOTP account.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Paste sign-in link, TOTP account
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /pair?user=${TOTP_ACCOUNT}&deliver=pbcopy
|
||||||
|
- runFlow: ../subflows/paste-link.yaml
|
||||||
|
- runFlow: ../subflows/finish-two-factor-if-asked.yaml
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- takeScreenshot: paste-totp-01-inbox
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# An expired code (codes live 2 minutes): the helper creates one now and
|
||||||
|
# opens it after 130 s. The app says so and stays on the sign-in screen.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Expired sign-in code
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /pair-later?user=${ACCOUNT}&deliver=openurl&delay=130
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/confirm-link.yaml
|
||||||
|
env:
|
||||||
|
LINK_WAIT: 200000
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "That code has expired.*"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: link-expired-01-notice
|
||||||
|
- assertVisible: "Enter server details manually"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# A code that was already used: sign in with it, sign out, open the same link
|
||||||
|
# again (same app process, then after a restart, which asks the server).
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Used sign-in code
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /pair?user=${ACCOUNT}&deliver=openurl
|
||||||
|
- runFlow: ../subflows/confirm-link.yaml
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- runFlow: ../subflows/sign-out.yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Enter server details manually"
|
||||||
|
timeout: 30000
|
||||||
|
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /redeliver?user=${ACCOUNT}&deliver=openurl
|
||||||
|
- runFlow: ../subflows/confirm-link.yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "That code was already used|That code has expired or was already used"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: link-used-01-same-process
|
||||||
|
|
||||||
|
- stopApp
|
||||||
|
- launchApp
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Enter server details manually"
|
||||||
|
timeout: 60000
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /redeliver?user=${ACCOUNT}&deliver=openurl
|
||||||
|
- runFlow: ../subflows/confirm-link.yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "That code was already used|That code has expired or was already used"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: link-used-02-after-restart
|
||||||
|
- assertVisible: "Enter server details manually"
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# The QR scanner with camera access denied, then allowed (the simulator has
|
||||||
|
# no camera, so nothing can be scanned). Neither may crash the app.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Scanner without camera
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/start.yaml
|
||||||
|
env:
|
||||||
|
CAMERA: deny
|
||||||
|
- tapOn: "Scan a sign-in code.*"
|
||||||
|
# Denied for good: "Camera access is disabled..."; not asked yet (the
|
||||||
|
# simulator doesn't always apply the denial up front): "Bulwark Mail needs
|
||||||
|
# camera access..." (English catalog) or the built-in fallback wording.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Camera access is.*|.*needs camera access.*|Scanning a sign-in QR code uses the camera.*"
|
||||||
|
timeout: 15000
|
||||||
|
- takeScreenshot: scanner-01-denied
|
||||||
|
- tapOn: "Close"
|
||||||
|
- assertVisible: "Enter server details manually"
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/start.yaml
|
||||||
|
env:
|
||||||
|
CAMERA: allow
|
||||||
|
- tapOn: "Scan a sign-in code.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign-in code"
|
||||||
|
timeout: 15000
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- takeScreenshot: scanner-02-allowed
|
||||||
|
- tapOn: "Close"
|
||||||
|
- assertVisible: "Enter server details manually"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# "Use my email address": example.org has no autodiscovery, so the app moves
|
||||||
|
# on to the server step and names the domain.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Email address discovery
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- tapOn: "Use my email address.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "What's your email address."
|
||||||
|
timeout: 10000
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- inputText: ${ACCOUNT}@example.org
|
||||||
|
- assertVisible: ${ACCOUNT}@example.org
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "We couldn't find a server for example.org.*"
|
||||||
|
timeout: 60000
|
||||||
|
- takeScreenshot: discovery-01-not-found
|
||||||
|
- assertVisible:
|
||||||
|
id: login-server-input
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Second account by password via "Another account on <host>", then switch
|
||||||
|
# between the two.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Add account, password
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/password-sign-in.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
- runFlow: ../subflows/open-add-account.yaml
|
||||||
|
- takeScreenshot: add-password-01-choices
|
||||||
|
- tapOn: "Another account on .*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign in with a password instead"
|
||||||
|
timeout: 20000
|
||||||
|
- tapOn: "Sign in with a password instead"
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/password-form.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT2}@example.org
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/check-active-account.yaml
|
||||||
|
env:
|
||||||
|
ACTIVE: ${ACCOUNT2}@example.org
|
||||||
|
- takeScreenshot: add-password-02-two-accounts
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Second account by OAuth. Adding uses an ephemeral auth session, so the
|
||||||
|
# first account's Stalwart sign-in must not be reused.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Add account, OAuth
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/manual-server.yaml
|
||||||
|
env:
|
||||||
|
HOST: ${SERVER}
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- runFlow: ../subflows/allow-auth-session.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/stalwart-login-page.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
|
||||||
|
- runFlow: ../subflows/open-add-account.yaml
|
||||||
|
- tapOn: "Another account on .*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign in with a password instead"
|
||||||
|
timeout: 20000
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: ".*Wants to Use.*to Sign In.*"
|
||||||
|
commands:
|
||||||
|
- runFlow: ../subflows/allow-auth-session.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/stalwart-login-page.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT2}@example.org
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/check-active-account.yaml
|
||||||
|
env:
|
||||||
|
ACTIVE: ${ACCOUNT2}@example.org
|
||||||
|
- takeScreenshot: add-oauth-01-two-accounts
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Second account via the webmail hand-off, on top of a password account.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Add account, webmail
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/password-sign-in.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
- runFlow: ../subflows/open-add-account.yaml
|
||||||
|
- tapOn: "An account somewhere else.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "What's the other address."
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn: "I know my server address"
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/type.yaml
|
||||||
|
env:
|
||||||
|
FIELD: login-server-input
|
||||||
|
TAP: "no"
|
||||||
|
TEXT: ${WEBMAIL}
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign in with a password instead"
|
||||||
|
timeout: 20000
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: ".*Wants to Use.*to Sign In.*"
|
||||||
|
commands:
|
||||||
|
- runFlow: ../subflows/allow-auth-session.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/webmail-login-page.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT2}@example.org
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/check-active-account.yaml
|
||||||
|
env:
|
||||||
|
ACTIVE: ${ACCOUNT2}@example.org
|
||||||
|
- takeScreenshot: add-webmail-01-two-accounts
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Second account from a sign-in link opened by another app while signed in.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Add account, sign-in link
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/password-sign-in.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /pair?user=${ACCOUNT2}&deliver=openurl
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Open in .*Bulwark Mobile.*|Sign in with a link."
|
||||||
|
timeout: 60000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Open in .*Bulwark Mobile.*"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Open"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign in with a link."
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: add-link-01-prompt
|
||||||
|
- assertVisible: "This link adds an account.*"
|
||||||
|
- tapOn:
|
||||||
|
text: "Continue"
|
||||||
|
below: "Sign in with a link."
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/check-active-account.yaml
|
||||||
|
env:
|
||||||
|
ACTIVE: ${ACCOUNT2}@example.org
|
||||||
|
- takeScreenshot: add-link-02-two-accounts
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Sign in via webmail while the browser still has a webmail session (from
|
||||||
|
# 06-webmail, or from using the webmail in Safari): the hand-off must still
|
||||||
|
# reach the app. Before the fix, the webmail's login page redirected an
|
||||||
|
# existing session to its own inbox inside the sign-in sheet, and the app
|
||||||
|
# never got the sign-in.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
name: Webmail hand-off with an existing webmail session
|
||||||
|
tags:
|
||||||
|
- signin
|
||||||
|
---
|
||||||
|
# Leaves a webmail session in the shared browser cookies.
|
||||||
|
- runFlow: ../subflows/start.yaml
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/manual-server.yaml
|
||||||
|
env:
|
||||||
|
HOST: ${WEBMAIL}
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- runFlow: ../subflows/allow-auth-session.yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "user@example.com|Hand over to the app|.*Inbox.*"
|
||||||
|
timeout: 60000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "user@example.com"
|
||||||
|
commands:
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/webmail-login-page.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Hand over to the app"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Hand over to the app"
|
||||||
|
- runFlow: ../subflows/inbox.yaml
|
||||||
|
- runFlow: ../subflows/sign-out.yaml
|
||||||
|
|
||||||
|
# Again: the webmail now knows the user.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Enter server details manually"
|
||||||
|
timeout: 30000
|
||||||
|
- runFlow:
|
||||||
|
file: ../subflows/manual-server.yaml
|
||||||
|
env:
|
||||||
|
HOST: ${WEBMAIL}
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- runFlow: ../subflows/allow-auth-session.yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "user@example.com|Hand over to the app|Search mail.*"
|
||||||
|
timeout: 60000
|
||||||
|
- takeScreenshot: webmail-session-01-second-visit
|
||||||
|
- assertNotVisible: "Search mail.*"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# iOS asks before an app opens a sign-in session in the system browser:
|
||||||
|
# "“Bulwark Mobile” Wants to Use “<domain>” to Sign In". Accept it.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Wants to Use.*to Sign In.*"
|
||||||
|
timeout: 30000
|
||||||
|
- takeScreenshot: auth-session-alert
|
||||||
|
- tapOn:
|
||||||
|
text: "Continue"
|
||||||
|
below: ".*Wants to Use.*to Sign In.*"
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# The app asks for notification permission now and then (calendar
|
||||||
|
# reminders, push). The system alert covers the whole app, so XCTest sees
|
||||||
|
# nothing else until it is answered: allow it when it is up. Taps on system
|
||||||
|
# alerts miss in landscape, so answer it in portrait.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: ".*Would Like to Send You Notifications.*"
|
||||||
|
commands:
|
||||||
|
- setOrientation: PORTRAIT
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
# Maestro repeats a tap that seems to change nothing; here the repeat
|
||||||
|
# landed on the app once the alert was gone (a day in the calendar's
|
||||||
|
# month grid, which opened that day). Tap once.
|
||||||
|
- tapOn:
|
||||||
|
text: "Allow"
|
||||||
|
optional: true
|
||||||
|
retryTapIfNoChange: false
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${ORIENTATION == 'landscape'}
|
||||||
|
commands:
|
||||||
|
- setOrientation: LANDSCAPE_LEFT
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# The helper checks over JMAP that AS (default userb) received SUBJECT; with
|
||||||
|
# ATTACHMENT set, that the message carries that file.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /find?as=${AS || 'userb'}&subject=${encodeURIComponent(SUBJECT)}&timeout=60
|
||||||
|
- assertTrue:
|
||||||
|
condition: ${output.helper.found}
|
||||||
|
label: "${SUBJECT} delivered to ${AS || 'userb'}"
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${!!ATTACHMENT}
|
||||||
|
commands:
|
||||||
|
- assertTrue:
|
||||||
|
condition: ${output.helper.attachments.indexOf(ATTACHMENT) >= 0}
|
||||||
|
label: "${SUBJECT} carries ${ATTACHMENT}"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# The Settings account card names the active account ACTIVE, and the Account
|
||||||
|
# page lists another account to switch to. Goes back to Mail afterwards.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- tapOn:
|
||||||
|
id: tab-settings
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*${ACTIVE}.*"
|
||||||
|
timeout: 20000
|
||||||
|
- tapOn: ".*${ACTIVE}.*"
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: "Switch to this account"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: accounts-${ACTIVE}
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Empty the field with id FIELD. A tap puts the cursor where it lands and
|
||||||
|
# eraseText only deletes before the cursor, so erase in passes: each tap
|
||||||
|
# lands in the middle of what is left.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- repeat:
|
||||||
|
times: 4
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
id: ${FIELD}
|
||||||
|
- eraseText: 100
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Leave the message view: Back on a phone; the split layout keeps the list.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Back"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Back"
|
||||||
|
# A tap during the transition lands on stale coordinates.
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# In the composer: type BODY into the editor. The rich editor is a WebView
|
||||||
|
# whose placeholder is CSS, so it may not be exposed; then tap the middle of
|
||||||
|
# the screen, which is the body area below Subject (above the keyboard).
|
||||||
|
# FROM_SUBJECT=yes: the Subject field has the focus; Return there moves it
|
||||||
|
# to the body (an iPad in landscape shows hardly any body with the keyboard
|
||||||
|
# up, so there is nothing to tap).
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${FROM_SUBJECT == 'yes'}
|
||||||
|
commands:
|
||||||
|
- pressKey: Enter
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 2000
|
||||||
|
- inputText: ${BODY}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${FROM_SUBJECT != 'yes'}
|
||||||
|
commands:
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Write your message.*"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Write your message.*"
|
||||||
|
# A tap on the WebView doesn't always take the focus from the Subject field
|
||||||
|
# (on the iPad the body once went into Subject); a second tap does.
|
||||||
|
# (hideKeyboard fails here: the composer's keyboard has no dismiss key.)
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible: "Write your message.*"
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
point: "50%,50%"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 2000
|
||||||
|
- tapOn:
|
||||||
|
point: "50%,50%"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- inputText: ${BODY}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# A sign-in link from another app asks first. Continue.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
# simctl openurl acts like a link from another app: iOS asks first.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Open in .*Bulwark Mobile.*|Sign in with a link.|Open the sign-in page of.*"
|
||||||
|
timeout: ${LINK_WAIT || 60000}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Open in .*Bulwark Mobile.*"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Open"
|
||||||
|
# Right after a sign-out the app can take a while to get to the link.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign in with a link.|Open the sign-in page of.*"
|
||||||
|
timeout: 60000
|
||||||
|
- takeScreenshot: link-confirm-prompt
|
||||||
|
- tapOn:
|
||||||
|
text: "Continue"
|
||||||
|
below: "Sign in with a link."
|
||||||
|
# A tap while the alert is still animating in can go nowhere: tap again if
|
||||||
|
# it is still up.
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Sign in with a link."
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
text: "Continue"
|
||||||
|
below: "Sign in with a link."
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# iOS shows a one-time slide-to-type tip over the keyboard on a fresh
|
||||||
|
# simulator. The workflow turns it off, but dismiss it if it still shows up.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Speed up your typing by sliding.*"
|
||||||
|
commands:
|
||||||
|
# The sign-in screen has its own Continue button; take the tip's.
|
||||||
|
- tapOn:
|
||||||
|
text: "Continue"
|
||||||
|
below: "Speed up your typing by sliding.*"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Some routes hand the app a password for a TOTP account; the app then asks
|
||||||
|
# for a code itself. Answer it when it shows up.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Authentication code|Inbox"
|
||||||
|
timeout: 90000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Authentication code"
|
||||||
|
commands:
|
||||||
|
- runFlow: two-factor.yaml
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Open folder FOLDER from the drawer or docked sidebar.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- runFlow: open-drawer.yaml
|
||||||
|
# A folder row's label joins its texts: "<name>, <unread>, /, <total>".
|
||||||
|
- tapOn: ${FOLDER}([ ,].*)?
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ${FOLDER}
|
||||||
|
timeout: 15000
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Back to the inbox list, from wherever in the Mail tab.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Back"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Back"
|
||||||
|
- runFlow:
|
||||||
|
file: go-to-folder.yaml
|
||||||
|
env:
|
||||||
|
FOLDER: Inbox
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: email-row
|
||||||
|
timeout: 60000
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Signed in: the tab bar is there and the inbox shows messages. Dismisses the
|
||||||
|
# one-time prompts on the way.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: tab-mail
|
||||||
|
timeout: 90000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Not now"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Not now"
|
||||||
|
# Adding an account from Settings returns to Settings.
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: email-row
|
||||||
|
timeout: 60000
|
||||||
|
# Rows are one element: sender, subject and preview together. Mail the
|
||||||
|
# tests sent earlier can push the seeded row below the fold.
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: ${INBOX_TEXT || '.*Your verification code.*'}
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 30000
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# From the sign-in choices: enter a server address by hand and continue to the
|
||||||
|
# confirm step. env HOST: the address as a user types it.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- tapOn: "Enter server details manually"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: login-server-input
|
||||||
|
timeout: 10000
|
||||||
|
- runFlow:
|
||||||
|
file: type.yaml
|
||||||
|
env:
|
||||||
|
FIELD: login-server-input
|
||||||
|
TAP: "no"
|
||||||
|
TEXT: ${HOST}
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign in with a password instead"
|
||||||
|
timeout: 20000
|
||||||
|
optional: true
|
||||||
|
# Once in a while the tap doesn't reach the button (it moves as the keyboard
|
||||||
|
# goes away): tap again if the form is still there.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible:
|
||||||
|
id: login-server-input
|
||||||
|
commands:
|
||||||
|
- tapOn: "Continue"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign in with a password instead"
|
||||||
|
timeout: 20000
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Signed in: Settings → account card → Account page → Add account, up to the
|
||||||
|
# add-mode choices. (On the iPad split layout the Account page is already
|
||||||
|
# open; tapping the card again is harmless.)
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- tapOn:
|
||||||
|
id: tab-settings
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*@example.org, .*"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn: ".*@example.org, .*"
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: "Add account"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 20000
|
||||||
|
- tapOn: "Add account"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Add another account"
|
||||||
|
timeout: 10000
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Show the folder list: the modal drawer, or nothing to do where the sidebar
|
||||||
|
# is docked (iPad landscape).
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Toggle menu"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Toggle menu"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Scheduled.*"
|
||||||
|
timeout: 5000
|
||||||
|
optional: true
|
||||||
|
# Once more if the first tap went to a list still settling.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible: "Scheduled.*"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Toggle menu"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Scheduled.*"
|
||||||
|
timeout: 10000
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# From a message list: open the message whose row mentions SUBJECT.
|
||||||
|
# A row near the bottom can count as visible while the phone's tab bar
|
||||||
|
# covers it (the tap then opens another tab): on the phone, scroll it to the
|
||||||
|
# middle. The iPad's list is a column Maestro's scroll (in the middle of the
|
||||||
|
# screen) doesn't reach; its rows fit on screen.
|
||||||
|
# The avatar's "Select <sender>: <subject>" checkbox mentions the subject
|
||||||
|
# too, and a tap on it selects the row instead of opening it (#91): match
|
||||||
|
# the row itself by its id.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${DEVICE != 'tablet'}
|
||||||
|
commands:
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element:
|
||||||
|
id: email-row
|
||||||
|
text: ".*${SUBJECT}.*"
|
||||||
|
direction: DOWN
|
||||||
|
centerElement: true
|
||||||
|
visibilityPercentage: 100
|
||||||
|
timeout: 30000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${DEVICE == 'tablet'}
|
||||||
|
commands:
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element:
|
||||||
|
id: email-row
|
||||||
|
text: ".*${SUBJECT}.*"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 30000
|
||||||
|
# A tap while the list still moves lands on another row.
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- tapOn:
|
||||||
|
id: email-row
|
||||||
|
text: ".*${SUBJECT}.*"
|
||||||
|
# The message toolbar's "more" button: "Reply All" shows on the phone, but
|
||||||
|
# the iPad pane has a quick reply box instead for mail to one recipient.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "more"
|
||||||
|
timeout: 20000
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# On the password step: fill in LOGIN and PASSWORD and sign in.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: login-username-input
|
||||||
|
timeout: 20000
|
||||||
|
- runFlow:
|
||||||
|
file: type.yaml
|
||||||
|
env:
|
||||||
|
FIELD: login-username-input
|
||||||
|
TEXT: ${LOGIN}
|
||||||
|
- runFlow:
|
||||||
|
file: type-secret.yaml
|
||||||
|
env:
|
||||||
|
FIELD: login-password-input
|
||||||
|
TEXT: ${PASSWORD}
|
||||||
|
# Return on the password field signs in.
|
||||||
|
- pressKey: Enter
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# From the sign-in choices to the inbox: manual server, password.
|
||||||
|
# env LOGIN: the account's address.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- runFlow:
|
||||||
|
file: manual-server.yaml
|
||||||
|
env:
|
||||||
|
HOST: ${SERVER}
|
||||||
|
- tapOn: "Sign in with a password instead"
|
||||||
|
- runFlow: password-form.yaml
|
||||||
|
- runFlow: inbox.yaml
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# The helper has put a sign-in link on the simulator's clipboard: open
|
||||||
|
# "Paste sign-in link", paste it and sign in.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- tapOn: "Paste sign-in link.*"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Paste"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn: "Paste"
|
||||||
|
# iOS asks before an app reads the clipboard.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Allow Paste"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Allow Paste"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "bulwarkmail://pair.*"
|
||||||
|
timeout: 10000
|
||||||
|
- takeScreenshot: paste-link-filled
|
||||||
|
- tapOn:
|
||||||
|
text: "Sign in"
|
||||||
|
below: "bulwarkmail://pair.*"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Push a message with SUBJECT to ACCOUNT (helper /simpush) and tap its banner
|
||||||
|
# where it appears, at once (it shows for a few seconds, about as long as
|
||||||
|
# Maestro takes to find it). If the tap came too early or too late, push
|
||||||
|
# again: up to three tries until the message is open.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- repeat:
|
||||||
|
while:
|
||||||
|
notVisible: "Reply All"
|
||||||
|
times: 3
|
||||||
|
commands:
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /simpush?to=${ACCOUNT}&subject=${encodeURIComponent(SUBJECT)}
|
||||||
|
- tapOn:
|
||||||
|
point: "50%,9%"
|
||||||
|
waitToSettleTimeoutMs: 500
|
||||||
|
retryTapIfNoChange: false
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Reply All"
|
||||||
|
timeout: 30000
|
||||||
|
optional: true
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Settings list: find the row ROW (regex on "Title, summary"), open it,
|
||||||
|
# check PROOF shows, screenshot, and go back on a phone.
|
||||||
|
#
|
||||||
|
# Rows count as visible while the phone's tab bar or header covers them or
|
||||||
|
# while they hang half off the iPad's screen, and a tap then misses; the
|
||||||
|
# iPad's list is a left column Maestro's scroll (in the middle of the screen)
|
||||||
|
# doesn't reach. So search for the page (Q, a word of its title): the row
|
||||||
|
# then sits right under the search box. The account card (no Q) is always
|
||||||
|
# at the top.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${!!Q}
|
||||||
|
commands:
|
||||||
|
- tapOn: "Search settings"
|
||||||
|
- eraseText: 40
|
||||||
|
- inputText: ${Q}
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
# Now and then the simulator drops or garbles a typed character
|
||||||
|
# (seen: "Storagew,"): type it again, a few times if need be.
|
||||||
|
- repeat:
|
||||||
|
while:
|
||||||
|
notVisible: ${ROW}
|
||||||
|
times: 3
|
||||||
|
commands:
|
||||||
|
- tapOn: "Search settings"
|
||||||
|
- eraseText: 40
|
||||||
|
- inputText: ${Q}
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
# A tap while the list still coasts from the swipe only stops it.
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- tapOn: ${ROW}
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
# On the phone the page replaces the list: make sure it opened before
|
||||||
|
# scrolling (else the swipes below scroll the list away).
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${DEVICE != 'tablet'}
|
||||||
|
commands:
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible: "Back"
|
||||||
|
commands:
|
||||||
|
- tapOn: ${ROW}
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Back"
|
||||||
|
timeout: 10000
|
||||||
|
# PROOF can be further down the page: scroll the page (the right-hand pane
|
||||||
|
# on the iPad) until it shows.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${DEVICE == 'tablet'}
|
||||||
|
commands:
|
||||||
|
- repeat:
|
||||||
|
while:
|
||||||
|
notVisible: ${PROOF}
|
||||||
|
times: 6
|
||||||
|
commands:
|
||||||
|
- swipe:
|
||||||
|
start: 75%,80%
|
||||||
|
end: 75%,40%
|
||||||
|
duration: 600
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${DEVICE != 'tablet'}
|
||||||
|
commands:
|
||||||
|
- repeat:
|
||||||
|
while:
|
||||||
|
notVisible: ${PROOF}
|
||||||
|
times: 6
|
||||||
|
commands:
|
||||||
|
- swipe:
|
||||||
|
start: 50%,80%
|
||||||
|
end: 50%,40%
|
||||||
|
duration: 600
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ${PROOF}
|
||||||
|
timeout: 20000
|
||||||
|
- takeScreenshot: settings-${NAME}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Back"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Back"
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Signed in: sign out of the active account from the end of Settings.
|
||||||
|
# On the iPad the Settings list is the left column; scroll that one.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- tapOn:
|
||||||
|
id: tab-settings
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${DEVICE == 'tablet'}
|
||||||
|
commands:
|
||||||
|
- repeat:
|
||||||
|
while:
|
||||||
|
notVisible: "Sign out"
|
||||||
|
times: 12
|
||||||
|
commands:
|
||||||
|
- swipe:
|
||||||
|
start: 30%,85%
|
||||||
|
end: 30%,30%
|
||||||
|
duration: 500
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${DEVICE != 'tablet'}
|
||||||
|
commands:
|
||||||
|
- scrollUntilVisible:
|
||||||
|
element: "Sign out"
|
||||||
|
direction: DOWN
|
||||||
|
timeout: 30000
|
||||||
|
# The scroll can stop with the button half under the tab bar, where
|
||||||
|
# a tap reaches the tab bar instead: scroll on to the end of the list.
|
||||||
|
- swipe:
|
||||||
|
start: 50%,70%
|
||||||
|
end: 50%,30%
|
||||||
|
duration: 400
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- tapOn: "Sign out"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "The account is removed from this device.*"
|
||||||
|
timeout: 10000
|
||||||
|
# The dialog's button, not the Settings row of the same name behind it.
|
||||||
|
- tapOn:
|
||||||
|
text: "Sign out"
|
||||||
|
rightOf: "Cancel"
|
||||||
|
below: "The account is removed from this device.*"
|
||||||
|
- takeScreenshot: sign-out-tapped
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Feature flows start here: the app signed in to ACCOUNT, on the inbox.
|
||||||
|
# Keeps the app's data between flows; signs in only when needed.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- launchApp:
|
||||||
|
permissions:
|
||||||
|
notifications: allow
|
||||||
|
photos: allow
|
||||||
|
camera: allow
|
||||||
|
# Answer the notification prompt (if the app asks) before turning to
|
||||||
|
# landscape: rotated, taps on system alerts miss.
|
||||||
|
- setOrientation: PORTRAIT
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Enter server details manually|Inbox|.*Would Like to Send You Notifications.*"
|
||||||
|
timeout: 120000
|
||||||
|
- runFlow: allow-notifications.yaml
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${ORIENTATION == 'landscape'}
|
||||||
|
commands:
|
||||||
|
- setOrientation: LANDSCAPE_LEFT
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Enter server details manually|Inbox"
|
||||||
|
timeout: 30000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Enter server details manually"
|
||||||
|
commands:
|
||||||
|
- runFlow:
|
||||||
|
file: password-sign-in.yaml
|
||||||
|
env:
|
||||||
|
LOGIN: ${ACCOUNT}@example.org
|
||||||
|
- runFlow: allow-notifications.yaml
|
||||||
|
- tapOn:
|
||||||
|
id: tab-mail
|
||||||
|
- runFlow: go-to-inbox.yaml
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Stalwart's own sign-in page inside the auth session. env LOGIN, TOTP (any
|
||||||
|
# non-empty value: the account needs a one-time code).
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "you@example.com"
|
||||||
|
timeout: 60000
|
||||||
|
- takeScreenshot: stalwart-login-page
|
||||||
|
- runFlow:
|
||||||
|
file: type-web.yaml
|
||||||
|
env:
|
||||||
|
HINT: "you@example.com"
|
||||||
|
TEXT: ${LOGIN}
|
||||||
|
- tapOn: "••••••••"
|
||||||
|
- inputText: ${PASSWORD}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${TOTP == 'yes'}
|
||||||
|
commands:
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /totp
|
||||||
|
- tapOn: "000000"
|
||||||
|
- inputText: ${output.helper.code}
|
||||||
|
- tapOn:
|
||||||
|
text: "Sign in"
|
||||||
|
index: 1
|
||||||
|
# Stalwart 1.0.1 asks for consent after Sign in, once per client grant;
|
||||||
|
# 0.16 and repeat sign-ins go straight back to the app.
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 5000
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Allow"
|
||||||
|
commands:
|
||||||
|
- takeScreenshot: stalwart-consent-page
|
||||||
|
- tapOn: "Allow"
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Fresh install state, rotated for the landscape leg, on the sign-in choices.
|
||||||
|
# env CAMERA: allow (default) or deny.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
# A system prompt left over from an earlier flow ("Open in …?") stays on
|
||||||
|
# screen across app launches.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Open in .*"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Cancel"
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
clearKeychain: true
|
||||||
|
permissions:
|
||||||
|
notifications: allow
|
||||||
|
camera: ${CAMERA || 'allow'}
|
||||||
|
photos: allow
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${ORIENTATION == 'landscape'}
|
||||||
|
commands:
|
||||||
|
- setOrientation: LANDSCAPE_LEFT
|
||||||
|
# A cold start of a release build on a CI simulator can be slow.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Enter server details manually"
|
||||||
|
timeout: 120000
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# The two-factor field is showing (it focuses itself): type a fresh code from
|
||||||
|
# the helper and sign in.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Authentication code"
|
||||||
|
timeout: 30000
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /totp
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- inputText: ${output.helper.code}
|
||||||
|
# The number pad has no return key.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible: "Sign in"
|
||||||
|
commands:
|
||||||
|
- hideKeyboard
|
||||||
|
- tapOn: "Sign in"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Type a secret into the field with id FIELD (TAP=no for a field that focuses
|
||||||
|
# itself; see type.yaml for why the first character goes alone). The value
|
||||||
|
# can't be checked (a secure field shows dots) and must never be shown, so a
|
||||||
|
# lost keystroke surfaces as a failed sign-in.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- evalScript: ${output.secretHead = TEXT.slice(0, 1); output.secretTail = TEXT.slice(1)}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${TAP != 'no'}
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
id: ${FIELD}
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- inputText: ${output.secretHead}
|
||||||
|
# A short pause: wait for something that never shows.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "zz-no-such-element-zz"
|
||||||
|
timeout: 1000
|
||||||
|
optional: true
|
||||||
|
- inputText: ${output.secretTail}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Type TEXT into a web page field found by its placeholder HINT (the sign-in
|
||||||
|
# pages in the auth session), and check it arrived as typed. Now and then a
|
||||||
|
# stray character lands in front of it on the CI simulator; then erase the
|
||||||
|
# field and type again. The caret is still at the end of what was just
|
||||||
|
# typed, so eraseText (which deletes before the caret) empties it; selecting
|
||||||
|
# the text with a long press didn't work and appended instead. SECRET=yes
|
||||||
|
# skips the check (a password field shows dots). TAP=no types into the
|
||||||
|
# field that has the focus already.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${TAP != 'no'}
|
||||||
|
commands:
|
||||||
|
- tapOn: ${HINT}
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
- inputText: ${TEXT}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${SECRET != 'yes'}
|
||||||
|
commands:
|
||||||
|
- repeat:
|
||||||
|
while:
|
||||||
|
notVisible: ${TEXT}
|
||||||
|
times: 2
|
||||||
|
commands:
|
||||||
|
- eraseText: 150
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 2000
|
||||||
|
- inputText: ${TEXT}
|
||||||
|
- assertVisible: ${TEXT}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Type TEXT into the field with id FIELD and check it arrived in full.
|
||||||
|
#
|
||||||
|
# iOS drops keystrokes in two situations seen on the CI simulator: while the
|
||||||
|
# edit menu (the "AutoFill" bubble) opens, which happens when a flow taps a
|
||||||
|
# field that already has focus, and right after the first character, while
|
||||||
|
# the field re-renders. So: no tap for a field that focuses itself (TAP=no;
|
||||||
|
# Maestro can't see a React Native field's focus), type the first character,
|
||||||
|
# wait until it shows, then type the rest. Retyped up to twice.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- evalScript: ${output.typeHead = TEXT.slice(0, 1); output.typeTail = TEXT.slice(1)}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${TAP != 'no'}
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
id: ${FIELD}
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 3000
|
||||||
|
# ERASE=yes: the field already holds text.
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${ERASE == 'yes'}
|
||||||
|
commands:
|
||||||
|
- runFlow: clear-field.yaml
|
||||||
|
- inputText: ${output.typeHead}
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: ${FIELD}
|
||||||
|
text: "${output.typeHead}.*"
|
||||||
|
timeout: 5000
|
||||||
|
optional: true
|
||||||
|
- inputText: ${output.typeTail}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
notVisible:
|
||||||
|
id: ${FIELD}
|
||||||
|
text: ${TEXT_PATTERN || TEXT}
|
||||||
|
commands:
|
||||||
|
- retry:
|
||||||
|
maxRetries: 2
|
||||||
|
commands:
|
||||||
|
- runFlow: clear-field.yaml
|
||||||
|
- inputText: ${output.typeHead}
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: ${FIELD}
|
||||||
|
text: "${output.typeHead}.*"
|
||||||
|
timeout: 5000
|
||||||
|
optional: true
|
||||||
|
- inputText: ${output.typeTail}
|
||||||
|
- assertVisible:
|
||||||
|
id: ${FIELD}
|
||||||
|
text: ${TEXT_PATTERN || TEXT}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# The Bulwark webmail's sign-in page inside the auth session (the app's
|
||||||
|
# "Sign in via webmail" hand-off). env LOGIN, TOTP=yes for a TOTP account.
|
||||||
|
appId: org.bulwarkmail.mobile
|
||||||
|
---
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "user@example.com"
|
||||||
|
timeout: 60000
|
||||||
|
- takeScreenshot: webmail-login-page
|
||||||
|
- runFlow:
|
||||||
|
file: type-web.yaml
|
||||||
|
env:
|
||||||
|
HINT: "user@example.com"
|
||||||
|
TEXT: ${LOGIN}
|
||||||
|
# On the iPhone, Safari's keyboard toolbar (Previous, Next, Done) can sit
|
||||||
|
# over the password field, and a tap there leaves the focus in Email (the
|
||||||
|
# password then went into Email). Its Next moves the focus to the password.
|
||||||
|
- evalScript: ${output.webNext = false}
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Next"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Next"
|
||||||
|
- waitForAnimationToEnd:
|
||||||
|
timeout: 2000
|
||||||
|
- evalScript: ${output.webNext = true}
|
||||||
|
- runFlow:
|
||||||
|
file: type-web.yaml
|
||||||
|
env:
|
||||||
|
HINT: "Enter your password"
|
||||||
|
TEXT: ${PASSWORD}
|
||||||
|
SECRET: "yes"
|
||||||
|
TAP: "${output.webNext ? 'no' : 'yes'}"
|
||||||
|
- tapOn: "Sign in"
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
true: ${TOTP == 'yes'}
|
||||||
|
commands:
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "000000"
|
||||||
|
timeout: 30000
|
||||||
|
- runScript:
|
||||||
|
file: ../scripts/helper.js
|
||||||
|
env:
|
||||||
|
CALL: /totp
|
||||||
|
- tapOn: "000000"
|
||||||
|
- inputText: ${output.helper.code}
|
||||||
|
- tapOn: "Sign in|Verify"
|
||||||
|
|
||||||
|
# Before handing over, the webmail asks whether the app may take the sign-in.
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Hand over to the app"
|
||||||
|
timeout: 60000
|
||||||
|
- takeScreenshot: webmail-handover-prompt
|
||||||
|
- tapOn: "Hand over to the app"
|
||||||
@@ -12,6 +12,7 @@ import { Mail, Calendar, BookUser, HardDrive, Settings } from './src/components/
|
|||||||
|
|
||||||
import { startLiveUpdates, type LiveUpdatesHandle } from './src/api/push-stream';
|
import { startLiveUpdates, type LiveUpdatesHandle } from './src/api/push-stream';
|
||||||
import { jmapClient } from './src/api/jmap-client';
|
import { jmapClient } from './src/api/jmap-client';
|
||||||
|
import { pruneEmailSubmissions } from './src/api/submissions';
|
||||||
import type { StateChange } from './src/api/types';
|
import type { StateChange } from './src/api/types';
|
||||||
import { dispatchStateChange } from './src/lib/state-change-bus';
|
import { dispatchStateChange } from './src/lib/state-change-bus';
|
||||||
import {
|
import {
|
||||||
@@ -34,26 +35,24 @@ import {
|
|||||||
getInitialNotificationTap,
|
getInitialNotificationTap,
|
||||||
getStoredRelayBaseUrl,
|
getStoredRelayBaseUrl,
|
||||||
notificationTapJmapAccountId,
|
notificationTapJmapAccountId,
|
||||||
|
renewOtherPushAccounts,
|
||||||
resyncPushNotifications,
|
resyncPushNotifications,
|
||||||
teardownPushNotificationsForAccount,
|
teardownPushNotificationsForAccount,
|
||||||
type NotificationTapPayload,
|
type NotificationTapPayload,
|
||||||
} from './src/lib/push-notifications';
|
} from './src/lib/push-notifications';
|
||||||
import { addUnifiedPushEndpointListener } from './src/lib/unified-push';
|
import { addUnifiedPushEndpointListener } from './src/lib/unified-push';
|
||||||
|
|
||||||
|
// Pause between a mute change and the push-filter update it sends (#78).
|
||||||
|
const MUTES_RESYNC_DELAY_MS = 1_000;
|
||||||
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
|
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
|
||||||
import ComposeScreen from './src/screens/ComposeScreen';
|
|
||||||
import EmailThreadScreen from './src/screens/EmailThreadScreen';
|
import EmailThreadScreen from './src/screens/EmailThreadScreen';
|
||||||
import EmailSourceScreen from './src/screens/EmailSourceScreen';
|
|
||||||
import LoginScreen from './src/screens/LoginScreen';
|
import LoginScreen from './src/screens/LoginScreen';
|
||||||
import MailTab from './src/screens/MailTab';
|
import MailTab from './src/screens/MailTab';
|
||||||
import FilesScreen from './src/screens/FilesScreen';
|
|
||||||
import CalendarScreen from './src/screens/CalendarScreen';
|
|
||||||
import ContactsTab from './src/screens/ContactsTab';
|
|
||||||
import ContactDetailScreen from './src/screens/ContactDetailScreen';
|
import ContactDetailScreen from './src/screens/ContactDetailScreen';
|
||||||
import ContactFormScreen from './src/screens/ContactFormScreen';
|
|
||||||
import GroupDetailScreen from './src/screens/GroupDetailScreen';
|
import GroupDetailScreen from './src/screens/GroupDetailScreen';
|
||||||
import SettingsScreen from './src/screens/SettingsScreen';
|
import SettingsScreen from './src/screens/SettingsScreen';
|
||||||
import ScheduledScreen from './src/screens/ScheduledScreen';
|
// Screens that aren't on the way to the inbox load on first visit
|
||||||
import UnifiedInboxScreen from './src/screens/UnifiedInboxScreen';
|
// (getComponent below), so their modules stay out of the startup work.
|
||||||
import { useAccountStore } from './src/stores/account-store';
|
import { useAccountStore } from './src/stores/account-store';
|
||||||
import { useAuthStore } from './src/stores/auth-store';
|
import { useAuthStore } from './src/stores/auth-store';
|
||||||
import { useCalendarStore } from './src/stores/calendar-store';
|
import { useCalendarStore } from './src/stores/calendar-store';
|
||||||
@@ -62,6 +61,8 @@ import { useEmailStore } from './src/stores/email-store';
|
|||||||
import { loadDetail, prefetchMessage } from './src/lib/email-detail-cache';
|
import { loadDetail, prefetchMessage } from './src/lib/email-detail-cache';
|
||||||
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
|
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
|
||||||
import { useSettingsStore } from './src/stores/settings-store';
|
import { useSettingsStore } from './src/stores/settings-store';
|
||||||
|
import { startPolicySync, usePolicyFeature } from './src/stores/policy-store';
|
||||||
|
import { startScreenPrivacySync } from './src/lib/screen-privacy';
|
||||||
import { useLocaleStore } from './src/stores/locale-store';
|
import { useLocaleStore } from './src/stores/locale-store';
|
||||||
import { useLayout } from './src/lib/use-layout';
|
import { useLayout } from './src/lib/use-layout';
|
||||||
import { toast } from './src/stores/toast-store';
|
import { toast } from './src/stores/toast-store';
|
||||||
@@ -77,10 +78,12 @@ import { signOutWidgets, startWidgetSync } from './src/widgets/sync';
|
|||||||
import { draftContextFromEmail } from './src/lib/draft-context';
|
import { draftContextFromEmail } from './src/lib/draft-context';
|
||||||
import {
|
import {
|
||||||
acceptSignInLink,
|
acceptSignInLink,
|
||||||
|
enterLinkAccount,
|
||||||
handleDeepLink,
|
handleDeepLink,
|
||||||
parseDeepLink,
|
parseDeepLink,
|
||||||
routeParkedSignInLink,
|
routeParkedSignInLink,
|
||||||
shareToDeepLink,
|
shareToDeepLink,
|
||||||
|
type AccountSwitchResult,
|
||||||
type DeepLink,
|
type DeepLink,
|
||||||
} from './src/navigation/linking';
|
} from './src/navigation/linking';
|
||||||
import { usePendingSignInLinkStore } from './src/navigation/pending-sign-in-link';
|
import { usePendingSignInLinkStore } from './src/navigation/pending-sign-in-link';
|
||||||
@@ -91,6 +94,7 @@ import { useOfflineCacheStore } from './src/stores/offline-cache-store';
|
|||||||
import { useOutboxStore } from './src/stores/outbox-store';
|
import { useOutboxStore } from './src/stores/outbox-store';
|
||||||
import { runOfflineSync } from './src/lib/offline-sync';
|
import { runOfflineSync } from './src/lib/offline-sync';
|
||||||
import { spacing, typography, type ThemePalette } from './src/theme/tokens';
|
import { spacing, typography, type ThemePalette } from './src/theme/tokens';
|
||||||
|
import { perfEndAfterPaint, perfMarksEnabled, perfStart } from './src/lib/perf-marks';
|
||||||
import { useColors } from './src/theme/colors';
|
import { useColors } from './src/theme/colors';
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
@@ -101,20 +105,26 @@ const navigationRef = createNavigationContainerRef<RootStackParamList>();
|
|||||||
// on every getInitialURL(), and a sign-in link must not run twice.
|
// on every getInitialURL(), and a sign-in link must not run twice.
|
||||||
let initialSignInLinkRead = false;
|
let initialSignInLinkRead = false;
|
||||||
|
|
||||||
|
// Move to the account a notification, reminder or link names. An account
|
||||||
|
// that was signed out meanwhile has nothing safe to open.
|
||||||
|
async function switchToAccount(accountId: string): Promise<AccountSwitchResult> {
|
||||||
|
const auth = useAuthStore.getState();
|
||||||
|
if (auth.activeAccountId === accountId) return true;
|
||||||
|
if (!useAccountStore.getState().getAccountById(accountId)) return false;
|
||||||
|
await auth.switchAccount(accountId);
|
||||||
|
return useAuthStore.getState().activeAccountId === accountId ? 'switched' : false;
|
||||||
|
}
|
||||||
|
|
||||||
async function navigateToNotificationTap(payload: NotificationTapPayload): Promise<void> {
|
async function navigateToNotificationTap(payload: NotificationTapPayload): Promise<void> {
|
||||||
if (!navigationRef.isReady()) return;
|
if (!navigationRef.isReady()) return;
|
||||||
|
|
||||||
// The notification carries the account it was generated for. If the user
|
// The notification carries the account it was generated for. If the user
|
||||||
// has since switched to a different account (or had a different one active
|
// has since switched to a different account (or had a different one active
|
||||||
// when the notification arrived), opening EmailThread under the active
|
// when the notification arrived), opening EmailThread under the active
|
||||||
// account would fetch the email from the wrong server and fail.
|
// account would fetch the email from the wrong server and fail. After a
|
||||||
const auth = useAuthStore.getState();
|
// switch the stack starts over on that account's inbox, so Back from the
|
||||||
if (payload.accountId && payload.accountId !== auth.activeAccountId) {
|
// message lands there instead of on the old account's screens.
|
||||||
const account = useAccountStore.getState().getAccountById(payload.accountId);
|
if (!(await enterLinkAccount(navigationRef, payload.accountId, switchToAccount))) return;
|
||||||
if (!account) return; // account was logged out — nothing safe to open.
|
|
||||||
await auth.switchAccount(payload.accountId);
|
|
||||||
if (useAuthStore.getState().activeAccountId !== payload.accountId) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
prefetchMessage({ id: payload.emailId, threadId: payload.threadId });
|
prefetchMessage({ id: payload.emailId, threadId: payload.threadId });
|
||||||
navigationRef.navigate('EmailThread', {
|
navigationRef.navigate('EmailThread', {
|
||||||
@@ -132,12 +142,7 @@ async function navigateToNotificationTap(payload: NotificationTapPayload): Promi
|
|||||||
// under the account it was scheduled for. The target is parked for
|
// under the account it was scheduled for. The target is parked for
|
||||||
// CalendarScreen, which also picks it up when it mounts after a cold start.
|
// CalendarScreen, which also picks it up when it mounts after a cold start.
|
||||||
async function openCalendarReminder(target: CalendarReminderTarget): Promise<void> {
|
async function openCalendarReminder(target: CalendarReminderTarget): Promise<void> {
|
||||||
const auth = useAuthStore.getState();
|
if (!(await enterLinkAccount(navigationRef, target.appAccountId, switchToAccount))) return;
|
||||||
if (target.appAccountId && target.appAccountId !== auth.activeAccountId) {
|
|
||||||
if (!useAccountStore.getState().getAccountById(target.appAccountId)) return;
|
|
||||||
await auth.switchAccount(target.appAccountId);
|
|
||||||
if (useAuthStore.getState().activeAccountId !== target.appAccountId) return;
|
|
||||||
}
|
|
||||||
setPendingCalendarOpen(target);
|
setPendingCalendarOpen(target);
|
||||||
// On a cold start the navigator mounts right after the auth gate flips.
|
// On a cold start the navigator mounts right after the auth gate flips.
|
||||||
for (let attempt = 0; attempt < 50 && !navigationRef.isReady(); attempt++) {
|
for (let attempt = 0; attempt < 50 && !navigationRef.isReady(); attempt++) {
|
||||||
@@ -172,13 +177,7 @@ async function openDeepLink(link: DeepLink): Promise<void> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
switchAccount: async (accountId) => {
|
switchAccount: switchToAccount,
|
||||||
const auth = useAuthStore.getState();
|
|
||||||
if (auth.activeAccountId === accountId) return true;
|
|
||||||
if (!useAccountStore.getState().getAccountById(accountId)) return false;
|
|
||||||
await auth.switchAccount(accountId);
|
|
||||||
return useAuthStore.getState().activeAccountId === accountId;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
// A widget or a link elsewhere brought the app up for this; say it failed
|
// A widget or a link elsewhere brought the app up for this; say it failed
|
||||||
// rather than leave the user wondering why the app just opened.
|
// rather than leave the user wondering why the app just opened.
|
||||||
@@ -234,6 +233,12 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
|||||||
const hasContacts = useHasContacts();
|
const hasContacts = useHasContacts();
|
||||||
const hasFiles = useHasFiles();
|
const hasFiles = useHasFiles();
|
||||||
const disabledTabStyle = { opacity: 0.4 } as const;
|
const disabledTabStyle = { opacity: 0.4 } as const;
|
||||||
|
// The organisation policy can turn a whole area off (webmail feature
|
||||||
|
// gates); its tab goes away rather than showing as unavailable.
|
||||||
|
const calendarAllowed = usePolicyFeature('calendarEnabled');
|
||||||
|
const contactsAllowed = usePolicyFeature('contactsEnabled');
|
||||||
|
const filesAllowed = usePolicyFeature('filesEnabled');
|
||||||
|
const hiddenTab = { tabBarItemStyle: { display: 'none' as const }, tabBarButton: () => null };
|
||||||
const t = useLocaleStore((state) => state.t);
|
const t = useLocaleStore((state) => state.t);
|
||||||
// The webmail's navigation names; a tab the server lacks says so.
|
// The webmail's navigation names; a tab the server lacks says so.
|
||||||
const tabLabels = {
|
const tabLabels = {
|
||||||
@@ -258,6 +263,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
|||||||
<PushOnboardingPrompt />
|
<PushOnboardingPrompt />
|
||||||
<AppIconBadge />
|
<AppIconBadge />
|
||||||
<Tab.Navigator
|
<Tab.Navigator
|
||||||
|
screenListeners={perfMarksEnabled() ? ({ route }) => ({
|
||||||
|
tabPress: () => perfStart('tab-switch'),
|
||||||
|
focus: () => perfEndAfterPaint('tab-switch', { to: route.name }),
|
||||||
|
}) : undefined}
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
// Tabs out of view stop re-rendering until they are shown again.
|
// Tabs out of view stop re-rendering until they are shown again.
|
||||||
@@ -294,6 +303,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
|||||||
name="Mail"
|
name="Mail"
|
||||||
options={{
|
options={{
|
||||||
title: tabLabels.mail,
|
title: tabLabels.mail,
|
||||||
|
tabBarButtonTestID: 'tab-mail',
|
||||||
tabBarIcon: ({ color, size }) => <Mail size={size} color={color} />,
|
tabBarIcon: ({ color, size }) => <Mail size={size} color={color} />,
|
||||||
tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined,
|
tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined,
|
||||||
tabBarBadgeStyle: {
|
tabBarBadgeStyle: {
|
||||||
@@ -314,46 +324,52 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
|||||||
</Tab.Screen>
|
</Tab.Screen>
|
||||||
<Tab.Screen
|
<Tab.Screen
|
||||||
name="Calendar"
|
name="Calendar"
|
||||||
component={CalendarScreen}
|
getComponent={() => require('./src/screens/CalendarScreen').default}
|
||||||
options={{
|
options={{
|
||||||
title: tabLabels.calendar,
|
title: tabLabels.calendar,
|
||||||
|
tabBarButtonTestID: 'tab-calendar',
|
||||||
tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />,
|
tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />,
|
||||||
tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle,
|
tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle,
|
||||||
tabBarAccessibilityLabel: hasCalendar ? tabLabels.calendar : unavailable(tabLabels.calendar),
|
tabBarAccessibilityLabel: hasCalendar ? tabLabels.calendar : unavailable(tabLabels.calendar),
|
||||||
|
...(calendarAllowed ? null : hiddenTab),
|
||||||
}}
|
}}
|
||||||
listeners={{
|
listeners={{
|
||||||
tabPress: (e) => {
|
tabPress: (e) => {
|
||||||
if (!hasCalendar) e.preventDefault();
|
if (!hasCalendar || !calendarAllowed) e.preventDefault();
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tab.Screen
|
<Tab.Screen
|
||||||
name="Contacts"
|
name="Contacts"
|
||||||
component={ContactsTab}
|
getComponent={() => require('./src/screens/ContactsTab').default}
|
||||||
options={{
|
options={{
|
||||||
title: tabLabels.contacts,
|
title: tabLabels.contacts,
|
||||||
|
tabBarButtonTestID: 'tab-contacts',
|
||||||
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
|
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
|
||||||
tabBarItemStyle: hasContacts ? undefined : disabledTabStyle,
|
tabBarItemStyle: hasContacts ? undefined : disabledTabStyle,
|
||||||
tabBarAccessibilityLabel: hasContacts ? tabLabels.contacts : unavailable(tabLabels.contacts),
|
tabBarAccessibilityLabel: hasContacts ? tabLabels.contacts : unavailable(tabLabels.contacts),
|
||||||
|
...(contactsAllowed ? null : hiddenTab),
|
||||||
}}
|
}}
|
||||||
listeners={{
|
listeners={{
|
||||||
tabPress: (e) => {
|
tabPress: (e) => {
|
||||||
if (!hasContacts) e.preventDefault();
|
if (!hasContacts || !contactsAllowed) e.preventDefault();
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Tab.Screen
|
<Tab.Screen
|
||||||
name="Files"
|
name="Files"
|
||||||
component={FilesScreen}
|
getComponent={() => require('./src/screens/FilesScreen').default}
|
||||||
options={{
|
options={{
|
||||||
title: tabLabels.files,
|
title: tabLabels.files,
|
||||||
|
tabBarButtonTestID: 'tab-files',
|
||||||
tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />,
|
tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />,
|
||||||
tabBarItemStyle: hasFiles ? undefined : disabledTabStyle,
|
tabBarItemStyle: hasFiles ? undefined : disabledTabStyle,
|
||||||
tabBarAccessibilityLabel: hasFiles ? tabLabels.files : unavailable(tabLabels.files),
|
tabBarAccessibilityLabel: hasFiles ? tabLabels.files : unavailable(tabLabels.files),
|
||||||
|
...(filesAllowed ? null : hiddenTab),
|
||||||
}}
|
}}
|
||||||
listeners={{
|
listeners={{
|
||||||
tabPress: (e) => {
|
tabPress: (e) => {
|
||||||
if (!hasFiles) e.preventDefault();
|
if (!hasFiles || !filesAllowed) e.preventDefault();
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -361,6 +377,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
|||||||
name="Settings"
|
name="Settings"
|
||||||
options={{
|
options={{
|
||||||
title: tabLabels.settings,
|
title: tabLabels.settings,
|
||||||
|
tabBarButtonTestID: 'tab-settings',
|
||||||
tabBarIcon: ({ color, size }) => <Settings size={size} color={color} />,
|
tabBarIcon: ({ color, size }) => <Settings size={size} color={color} />,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -415,7 +432,17 @@ export default function App() {
|
|||||||
// Device sync (Android, #34): live changes, the app's own edits and the
|
// Device sync (Android, #34): live changes, the app's own edits and the
|
||||||
// foreground ask Android to sync; accounts are checked against Android's.
|
// foreground ask Android to sync; accounts are checked against Android's.
|
||||||
startDeviceSyncTriggers();
|
startDeviceSyncTriggers();
|
||||||
return useNetworkStore.getState().init();
|
// The organisation policy of the active account's webmail: the cached
|
||||||
|
// one at once, a fresh one on start and on every account switch.
|
||||||
|
const stopPolicySync = startPolicySync();
|
||||||
|
// Settings > Security > Block screenshots.
|
||||||
|
const stopScreenPrivacy = startScreenPrivacySync();
|
||||||
|
const stopNetwork = useNetworkStore.getState().init();
|
||||||
|
return () => {
|
||||||
|
stopPolicySync();
|
||||||
|
stopScreenPrivacy();
|
||||||
|
stopNetwork?.();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Home-screen widgets follow the signed-in data; once the last account is
|
// Home-screen widgets follow the signed-in data; once the last account is
|
||||||
@@ -628,18 +655,39 @@ export default function App() {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [addAccountForLink]);
|
}, [addAccountForLink]);
|
||||||
|
|
||||||
|
// The server refused the stored sign-in of an account (auth store
|
||||||
|
// keepForSignIn): open the sign-in to it, filled in, over what is cached.
|
||||||
|
const reauthTarget = useAuthStore((s) => s.reauth);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isAuthenticated || !reauthTarget) return;
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
for (let attempt = 0; attempt < 50 && !navigationRef.isReady(); attempt++) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
if (cancelled) return;
|
||||||
|
}
|
||||||
|
if (cancelled || !navigationRef.isReady()) return;
|
||||||
|
if (navigationRef.getCurrentRoute()?.name === 'AddAccount') return;
|
||||||
|
navigationRef.navigate('AddAccount');
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [isAuthenticated, reauthTarget]);
|
||||||
|
|
||||||
// Re-register the device with the configured relay once authenticated,
|
// Re-register the device with the configured relay once authenticated,
|
||||||
// and whenever the FCM token rotates. Honours the user's notification
|
// and whenever the FCM token rotates. Honours the user's notification
|
||||||
// preference - flipping it off tears down THIS account's subscription so
|
// preference - flipping it off tears down THIS account's subscription so
|
||||||
// notifications stop arriving for it. Other logged-in accounts keep their
|
// notifications stop arriving for it. Other logged-in accounts keep their
|
||||||
// setups intact. An account the user turned push off for, or whose device
|
// setups intact. An account the user turned push off for on this device
|
||||||
// registration was revoked, stays off (see resyncPushNotifications).
|
// stays off (see resyncPushNotifications).
|
||||||
const emailNotificationsEnabled = useSettingsStore(
|
const emailNotificationsEnabled = useSettingsStore(
|
||||||
(s) => s.emailNotificationsEnabled,
|
(s) => s.emailNotificationsEnabled,
|
||||||
);
|
);
|
||||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isAuthenticated || !client) return;
|
// The setup asks the server through the client: it waits for the
|
||||||
|
// session, and runs again once one comes up (an account added or
|
||||||
|
// switched to, a cold start that began offline).
|
||||||
|
if (!isAuthenticated || !client || !haveLiveSession) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const doSetup = async () => {
|
const doSetup = async () => {
|
||||||
@@ -658,13 +706,16 @@ export default function App() {
|
|||||||
relayBaseUrl,
|
relayBaseUrl,
|
||||||
accountLabel: client.username ?? undefined,
|
accountLabel: client.username ?? undefined,
|
||||||
});
|
});
|
||||||
if (cancelled) return;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
'[push] relay setup failed:',
|
'[push] relay setup failed:',
|
||||||
error instanceof Error ? error.message : error,
|
error instanceof Error ? error.message : error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
// The other signed-in accounts' subscriptions expire too (webmail #1054); each
|
||||||
|
// is renewed through a client of its own.
|
||||||
|
await renewOtherPushAccounts().catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
void doSetup();
|
void doSetup();
|
||||||
@@ -682,7 +733,44 @@ export default function App() {
|
|||||||
unsubscribe();
|
unsubscribe();
|
||||||
unsubscribeUp();
|
unsubscribeUp();
|
||||||
};
|
};
|
||||||
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
|
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId, haveLiveSession]);
|
||||||
|
|
||||||
|
// Muting a folder or account changes the server-side push filter (#78),
|
||||||
|
// which the setup above would only send at the next launch. Send it now,
|
||||||
|
// after a short pause so a few taps in a row make one update. Compared by
|
||||||
|
// content, since hydration hands back an equal but new object; the value
|
||||||
|
// seen first after hydration is the baseline, not a change.
|
||||||
|
const notificationMutes = useSettingsStore((s) => s.notificationMutes);
|
||||||
|
// The Inbox-only switch changes the same filter (webmail #983).
|
||||||
|
const pushInboxOnly = useSettingsStore((s) => s.pushNotifyInboxOnly);
|
||||||
|
const settingsHydrated = useSettingsStore((s) => s.hydrated);
|
||||||
|
const mutesKey = JSON.stringify([notificationMutes, pushInboxOnly]);
|
||||||
|
const lastMutesKey = React.useRef<string | null>(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!settingsHydrated) return;
|
||||||
|
if (lastMutesKey.current === null || lastMutesKey.current === mutesKey) {
|
||||||
|
lastMutesKey.current = mutesKey;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastMutesKey.current = mutesKey;
|
||||||
|
if (!isAuthenticated || !client || !emailNotificationsEnabled) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
const relayBaseUrl = await getStoredRelayBaseUrl();
|
||||||
|
if (!relayBaseUrl || cancelled) return;
|
||||||
|
try {
|
||||||
|
await resyncPushNotifications({ relayBaseUrl, accountLabel: client.username ?? undefined });
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[push] mute update failed:', error instanceof Error ? error.message : error);
|
||||||
|
}
|
||||||
|
}, MUTES_RESYNC_DELAY_MS);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
// Only a change of the mutes should send an update; the rest is read live.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [mutesKey, settingsHydrated]);
|
||||||
|
|
||||||
// Live updates (SSE with polling fallback), re-armed on every account
|
// Live updates (SSE with polling fallback), re-armed on every account
|
||||||
// switch and every re-established session — the singleton `client` object
|
// switch and every re-established session — the singleton `client` object
|
||||||
@@ -739,6 +827,7 @@ export default function App() {
|
|||||||
void email.fetchMailboxes();
|
void email.fetchMailboxes();
|
||||||
if (email.currentMailboxId) void email.refreshEmails();
|
if (email.currentMailboxId) void email.refreshEmails();
|
||||||
void useOutboxStore.getState().flush();
|
void useOutboxStore.getState().flush();
|
||||||
|
void pruneEmailSubmissions();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Foreground liveness for the per-account connection dot: the open
|
// Foreground liveness for the per-account connection dot: the open
|
||||||
@@ -759,6 +848,11 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
void start();
|
void start();
|
||||||
|
// Stalwart 1.0 stops accepting sends once an account holds
|
||||||
|
// `maxSubmissions` submission records, so remove the ones that can no
|
||||||
|
// longer be cancelled (throttled per account; a resume or a send runs it
|
||||||
|
// again).
|
||||||
|
void pruneEmailSubmissions();
|
||||||
|
|
||||||
const subscription = AppState.addEventListener('change', (state) => {
|
const subscription = AppState.addEventListener('change', (state) => {
|
||||||
const nowActive = state === 'active';
|
const nowActive = state === 'active';
|
||||||
@@ -825,10 +919,10 @@ export default function App() {
|
|||||||
<Stack.Navigator screenOptions={{ headerShown: false, freezeOnBlur: true }} screenLayout={withHosts}>
|
<Stack.Navigator screenOptions={{ headerShown: false, freezeOnBlur: true }} screenLayout={withHosts}>
|
||||||
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
|
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
|
||||||
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
|
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
|
||||||
<Stack.Screen name="EmailSource" component={EmailSourceScreen} />
|
<Stack.Screen name="EmailSource" getComponent={() => require('./src/screens/EmailSourceScreen').default} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Compose"
|
name="Compose"
|
||||||
component={ComposeScreen}
|
getComponent={() => require('./src/screens/ComposeScreen').default}
|
||||||
// A tablet shows the composer as a panel over the screen it came
|
// A tablet shows the composer as a panel over the screen it came
|
||||||
// from, so that screen stays drawn behind the dimmed backdrop.
|
// from, so that screen stays drawn behind the dimmed backdrop.
|
||||||
options={isSplit
|
options={isSplit
|
||||||
@@ -838,15 +932,15 @@ export default function App() {
|
|||||||
<Stack.Screen name="ContactDetail" component={ContactDetailScreen} />
|
<Stack.Screen name="ContactDetail" component={ContactDetailScreen} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="ContactForm"
|
name="ContactForm"
|
||||||
component={ContactFormScreen}
|
getComponent={() => require('./src/screens/ContactFormScreen').default}
|
||||||
options={{
|
options={{
|
||||||
presentation: 'modal',
|
presentation: 'modal',
|
||||||
animation: 'slide_from_bottom',
|
animation: 'slide_from_bottom',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="GroupDetail" component={GroupDetailScreen} />
|
<Stack.Screen name="GroupDetail" component={GroupDetailScreen} />
|
||||||
<Stack.Screen name="Scheduled" component={ScheduledScreen} />
|
<Stack.Screen name="Scheduled" getComponent={() => require('./src/screens/ScheduledScreen').default} />
|
||||||
<Stack.Screen name="UnifiedInbox" component={UnifiedInboxScreen} />
|
<Stack.Screen name="UnifiedInbox" getComponent={() => require('./src/screens/UnifiedInboxScreen').default} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="AddAccount"
|
name="AddAccount"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
+4
-2
@@ -98,9 +98,11 @@ the audit's "Fix pass, 2026-09-24" section.
|
|||||||
| 07 | Filters (Sieve), vacation responder, Files | [docs/parity/07-filters-vacation-files.md](docs/parity/07-filters-vacation-files.md) | 32 | 29 | 3 | 3 | 8 | 21 |
|
| 07 | Filters (Sieve), vacation responder, Files | [docs/parity/07-filters-vacation-files.md](docs/parity/07-filters-vacation-files.md) | 32 | 29 | 3 | 3 | 8 | 21 |
|
||||||
| 08 | Settings, sync, push, i18n, themes, updates, misc UI | [docs/parity/08-settings-push-i18n-ui.md](docs/parity/08-settings-push-i18n-ui.md) | 46 | 38 | 8 | 0 | 14 | 32 |
|
| 08 | Settings, sync, push, i18n, themes, updates, misc UI | [docs/parity/08-settings-push-i18n-ui.md](docs/parity/08-settings-push-i18n-ui.md) | 46 | 38 | 8 | 0 | 14 | 32 |
|
||||||
| 09 | JMAP client core, live sync, offline, security, S/MIME | [docs/parity/09-jmap-core-sync-security.md](docs/parity/09-jmap-core-sync-security.md) | 56 | 54 | 2 | 7 | 23 | 26 |
|
| 09 | JMAP client core, live sync, offline, security, S/MIME | [docs/parity/09-jmap-core-sync-security.md](docs/parity/09-jmap-core-sync-security.md) | 56 | 54 | 2 | 7 | 23 | 26 |
|
||||||
| | **Total** | | **415** | **380** | **35** | **32** | **138** | **244** |
|
| 10 | Webmail 1.10.0 to 1.18.0 and Unreleased (all areas) | [docs/parity/10-webmail-1.10-to-1.18.md](docs/parity/10-webmail-1.10-to-1.18.md) | 112 | 52 | 60 | 10 | 40 | 62 |
|
||||||
|
| | **Total** | | **527** | **432** | **95** | **42** | **178** | **306** |
|
||||||
|
|
||||||
Counts are of the `- [ ]` and `- [x]` items per file as of 2026-09-24. Done and
|
Counts are of the `- [ ]` and `- [x]` items per file as of 2026-09-24 (row 10:
|
||||||
|
2026-10-29). Done and
|
||||||
Open split them by tick; the P columns count the priority tags on those items
|
Open split them by tick; the P columns count the priority tags on those items
|
||||||
(one item carries none).
|
(one item carries none).
|
||||||
|
|
||||||
|
|||||||
@@ -90,8 +90,8 @@ react {
|
|||||||
/* Variants */
|
/* Variants */
|
||||||
// The list of variants to that are debuggable. For those we're going to
|
// The list of variants to that are debuggable. For those we're going to
|
||||||
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
|
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
|
||||||
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
|
// The distribution flavors (github, play) need their debug variants listed.
|
||||||
// debuggableVariants = ["liteDebug", "prodDebug"]
|
debuggableVariants = ["githubDebug", "playDebug"]
|
||||||
|
|
||||||
/* Bundling */
|
/* Bundling */
|
||||||
// A list containing the node command and its flags. Default is just 'node'.
|
// A list containing the node command and its flags. Default is just 'node'.
|
||||||
@@ -154,13 +154,39 @@ android {
|
|||||||
versionCode appVersionCode.toInteger()
|
versionCode appVersionCode.toInteger()
|
||||||
versionName appVersionName
|
versionName appVersionName
|
||||||
|
|
||||||
// Android account type of device sync (#34), derived from the package
|
|
||||||
// name so a later rename needs no other change. The XML in res/xml and
|
|
||||||
// the Kotlin in sync/ read it as @string/device_sync_account_type.
|
|
||||||
resValue "string", "device_sync_account_type", "${applicationId}.account"
|
|
||||||
|
|
||||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the build is distributed. The two are separate apps on a phone:
|
||||||
|
*
|
||||||
|
* - github: the APK on GitHub Releases, sideloaded, updated by the in-app
|
||||||
|
* updater. Keeps the original package name so existing installs update.
|
||||||
|
* - play: the Google Play bundle. Play forbids an app updating itself
|
||||||
|
* outside Play, so this flavor has no installer permission and the JS
|
||||||
|
* hides the updater (BuildConfig.DISTRIBUTION, lib/platform-capabilities).
|
||||||
|
*
|
||||||
|
* See docs/android-release.md.
|
||||||
|
*/
|
||||||
|
flavorDimensions = ["distribution"]
|
||||||
|
productFlavors {
|
||||||
|
github {
|
||||||
|
dimension "distribution"
|
||||||
|
applicationId 'com.anonymous.bulwarkmobile'
|
||||||
|
buildConfigField "String", "DISTRIBUTION", '"github"'
|
||||||
|
}
|
||||||
|
play {
|
||||||
|
dimension "distribution"
|
||||||
|
applicationId 'org.bulwarkmail.mobile'
|
||||||
|
buildConfigField "String", "DISTRIBUTION", '"play"'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applicationVariants.all { variant ->
|
||||||
|
// Android account type of device sync (#34), derived from the package
|
||||||
|
// name of each flavor. The XML in res/xml and the Kotlin in sync/ read
|
||||||
|
// it as @string/device_sync_account_type.
|
||||||
|
variant.resValue "string", "device_sync_account_type", "${variant.applicationId}.account"
|
||||||
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
debug {
|
debug {
|
||||||
storeFile file('debug.keystore')
|
storeFile file('debug.keystore')
|
||||||
@@ -245,7 +271,7 @@ def checkReleaseSigning = tasks.register('checkReleaseSigning') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
tasks.configureEach { task ->
|
tasks.configureEach { task ->
|
||||||
if (task.name == 'preReleaseBuild') {
|
if (task.name ==~ /pre\w*ReleaseBuild/) {
|
||||||
task.dependsOn(checkReleaseSigning)
|
task.dependsOn(checkReleaseSigning)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"project_info": {
|
"project_info": {
|
||||||
"project_number": "504486613721",
|
"project_number": "752109678859",
|
||||||
"project_id": "bulwark-mobile",
|
"project_id": "bulwark-20c72",
|
||||||
"storage_bucket": "bulwark-mobile.firebasestorage.app"
|
"storage_bucket": "bulwark-20c72.firebasestorage.app"
|
||||||
},
|
},
|
||||||
"client": [
|
"client": [
|
||||||
{
|
{
|
||||||
"client_info": {
|
"client_info": {
|
||||||
"mobilesdk_app_id": "1:504486613721:android:69a5d7ba9148fb14cb24c8",
|
"mobilesdk_app_id": "1:752109678859:android:952e8373ad508e780cf20e",
|
||||||
"android_client_info": {
|
"android_client_info": {
|
||||||
"package_name": "com.anonymous.bulwarkmobile"
|
"package_name": "com.anonymous.bulwarkmobile"
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"oauth_client": [],
|
"oauth_client": [],
|
||||||
"api_key": [
|
"api_key": [
|
||||||
{
|
{
|
||||||
"current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno"
|
"current_key": "AIzaSyCpmCG1-Mnlgtme3NgtgRbmp7GztQeT4w4"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"services": {
|
"services": {
|
||||||
@@ -26,15 +26,15 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"client_info": {
|
"client_info": {
|
||||||
"mobilesdk_app_id": "1:504486613721:android:14c6a69aa49cac3ecb24c8",
|
"mobilesdk_app_id": "1:752109678859:android:2692d26569e5d9950cf20e",
|
||||||
"android_client_info": {
|
"android_client_info": {
|
||||||
"package_name": "com.bulwark.bulwarkmobile"
|
"package_name": "org.bulwarkmail.mobile"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"oauth_client": [],
|
"oauth_client": [],
|
||||||
"api_key": [
|
"api_key": [
|
||||||
{
|
{
|
||||||
"current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno"
|
"current_key": "AIzaSyCpmCG1-Mnlgtme3NgtgRbmp7GztQeT4w4"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"services": {
|
"services": {
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Debug builds load the bundle from Metro over http, which the main config
|
||||||
|
forbids (a network security config replaces the manifest's
|
||||||
|
usesCleartextTraffic). Same trust anchors as the release config.
|
||||||
|
-->
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="true">
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system" />
|
||||||
|
<certificates src="user" />
|
||||||
|
</trust-anchors>
|
||||||
|
</base-config>
|
||||||
|
</network-security-config>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The in-app updater hands the downloaded release APK to the system
|
||||||
|
installer (src/lib/install-update.ts). Only the sideloaded GitHub build
|
||||||
|
updates itself; the Play build is updated by Play. -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "504486613721",
|
||||||
|
"project_id": "bulwark-mobile",
|
||||||
|
"storage_bucket": "bulwark-mobile.firebasestorage.app"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:504486613721:android:69a5d7ba9148fb14cb24c8",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.anonymous.bulwarkmobile"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:504486613721:android:14c6a69aa49cac3ecb24c8",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.bulwark.bulwarkmobile"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyD4Zyu1BU4YNYy4SOK5te3EhSRHRGoMZno"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
@@ -2,9 +2,16 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.CAMERA"/>
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
<!-- REQUEST_INSTALL_PACKAGES (in-app updater) is declared by the github flavor only. -->
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
<!-- Photos are picked with the system photo picker, which needs no media
|
||||||
|
permission; Play's photo and video policy rejects READ_MEDIA_IMAGES for that. -->
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>
|
||||||
|
<!-- expo-camera declares the microphone for video; QR scanning never records audio. -->
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" tools:node="remove"/>
|
||||||
|
<!-- expo-screen-capture registers its screenshot listener on Android 14+ as soon
|
||||||
|
as it loads; without this permission the app crashes at launch. -->
|
||||||
|
<uses-permission android:name="android.permission.DETECT_SCREEN_CAPTURE"/>
|
||||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
<!-- Device sync (#34): contacts and calendars in Android's providers, driven by the sync framework. -->
|
<!-- Device sync (#34): contacts and calendars in Android's providers, driven by the sync framework. -->
|
||||||
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||||
@@ -21,7 +28,7 @@
|
|||||||
<data android:scheme="https"/>
|
<data android:scheme="https"/>
|
||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
|
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules" android:networkSecurityConfig="@xml/network_security_config">
|
||||||
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
||||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
||||||
@@ -43,6 +50,13 @@
|
|||||||
<category android:name="android.intent.category.BROWSABLE"/>
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
<data android:scheme="bulwarkmail"/>
|
<data android:scheme="bulwarkmail"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- Browser sign-in callback of the client the app registers at Stalwart 1.0 (org.bulwarkmail.mobile:/auth/callback). -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
|
<data android:scheme="org.bulwarkmail.mobile"/>
|
||||||
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW"/>
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
<action android:name="android.intent.action.SENDTO"/>
|
<action android:name="android.intent.action.SENDTO"/>
|
||||||
@@ -110,224 +124,224 @@
|
|||||||
<receiver android:name=".widget.InboxWidget" android:exported="false" android:label="@string/widgetprovider_inboxwidget_label">
|
<receiver android:name=".widget.InboxWidget" android:exported="false" android:label="@string/widgetprovider_inboxwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_inboxwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_inboxwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.TriageWidget" android:exported="false" android:label="@string/widgetprovider_triagewidget_label">
|
<receiver android:name=".widget.TriageWidget" android:exported="false" android:label="@string/widgetprovider_triagewidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_triagewidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_triagewidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.UnreadCountWidget" android:exported="false" android:label="@string/widgetprovider_unreadcountwidget_label">
|
<receiver android:name=".widget.UnreadCountWidget" android:exported="false" android:label="@string/widgetprovider_unreadcountwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_unreadcountwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_unreadcountwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.LatestMessageWidget" android:exported="false" android:label="@string/widgetprovider_latestmessagewidget_label">
|
<receiver android:name=".widget.LatestMessageWidget" android:exported="false" android:label="@string/widgetprovider_latestmessagewidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_latestmessagewidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_latestmessagewidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.FolderCountsWidget" android:exported="false" android:label="@string/widgetprovider_foldercountswidget_label">
|
<receiver android:name=".widget.FolderCountsWidget" android:exported="false" android:label="@string/widgetprovider_foldercountswidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_foldercountswidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_foldercountswidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.MailShortcutsWidget" android:exported="false" android:label="@string/widgetprovider_mailshortcutswidget_label">
|
<receiver android:name=".widget.MailShortcutsWidget" android:exported="false" android:label="@string/widgetprovider_mailshortcutswidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailshortcutswidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailshortcutswidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.AllAccountsWidget" android:exported="false" android:label="@string/widgetprovider_allaccountswidget_label">
|
<receiver android:name=".widget.AllAccountsWidget" android:exported="false" android:label="@string/widgetprovider_allaccountswidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_allaccountswidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_allaccountswidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.StarredWidget" android:exported="false" android:label="@string/widgetprovider_starredwidget_label">
|
<receiver android:name=".widget.StarredWidget" android:exported="false" android:label="@string/widgetprovider_starredwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_starredwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_starredwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.FavouritePeopleWidget" android:exported="false" android:label="@string/widgetprovider_favouritepeoplewidget_label">
|
<receiver android:name=".widget.FavouritePeopleWidget" android:exported="false" android:label="@string/widgetprovider_favouritepeoplewidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_favouritepeoplewidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_favouritepeoplewidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.MailSearchWidget" android:exported="false" android:label="@string/widgetprovider_mailsearchwidget_label">
|
<receiver android:name=".widget.MailSearchWidget" android:exported="false" android:label="@string/widgetprovider_mailsearchwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailsearchwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailsearchwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.OutboxWidget" android:exported="false" android:label="@string/widgetprovider_outboxwidget_label">
|
<receiver android:name=".widget.OutboxWidget" android:exported="false" android:label="@string/widgetprovider_outboxwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_outboxwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_outboxwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.TagWidget" android:exported="false" android:label="@string/widgetprovider_tagwidget_label">
|
<receiver android:name=".widget.TagWidget" android:exported="false" android:label="@string/widgetprovider_tagwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_tagwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_tagwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.LastDraftWidget" android:exported="false" android:label="@string/widgetprovider_lastdraftwidget_label">
|
<receiver android:name=".widget.LastDraftWidget" android:exported="false" android:label="@string/widgetprovider_lastdraftwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_lastdraftwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_lastdraftwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.AgendaWidget" android:exported="false" android:label="@string/widgetprovider_agendawidget_label">
|
<receiver android:name=".widget.AgendaWidget" android:exported="false" android:label="@string/widgetprovider_agendawidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_agendawidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_agendawidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.WeekWidget" android:exported="false" android:label="@string/widgetprovider_weekwidget_label">
|
<receiver android:name=".widget.WeekWidget" android:exported="false" android:label="@string/widgetprovider_weekwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_weekwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_weekwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.MonthWidget" android:exported="false" android:label="@string/widgetprovider_monthwidget_label">
|
<receiver android:name=".widget.MonthWidget" android:exported="false" android:label="@string/widgetprovider_monthwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_monthwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_monthwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.TodayEventsWidget" android:exported="false" android:label="@string/widgetprovider_todayeventswidget_label">
|
<receiver android:name=".widget.TodayEventsWidget" android:exported="false" android:label="@string/widgetprovider_todayeventswidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayeventswidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayeventswidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.NextEventWidget" android:exported="false" android:label="@string/widgetprovider_nexteventwidget_label">
|
<receiver android:name=".widget.NextEventWidget" android:exported="false" android:label="@string/widgetprovider_nexteventwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_nexteventwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_nexteventwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.DateNextWidget" android:exported="false" android:label="@string/widgetprovider_datenextwidget_label">
|
<receiver android:name=".widget.DateNextWidget" android:exported="false" android:label="@string/widgetprovider_datenextwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_datenextwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_datenextwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.UpNextWidget" android:exported="false" android:label="@string/widgetprovider_upnextwidget_label">
|
<receiver android:name=".widget.UpNextWidget" android:exported="false" android:label="@string/widgetprovider_upnextwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_upnextwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_upnextwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.InvitationsWidget" android:exported="false" android:label="@string/widgetprovider_invitationswidget_label">
|
<receiver android:name=".widget.InvitationsWidget" android:exported="false" android:label="@string/widgetprovider_invitationswidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_invitationswidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_invitationswidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.FreeTimeWidget" android:exported="false" android:label="@string/widgetprovider_freetimewidget_label">
|
<receiver android:name=".widget.FreeTimeWidget" android:exported="false" android:label="@string/widgetprovider_freetimewidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_freetimewidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_freetimewidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.TasksWidget" android:exported="false" android:label="@string/widgetprovider_taskswidget_label">
|
<receiver android:name=".widget.TasksWidget" android:exported="false" android:label="@string/widgetprovider_taskswidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskswidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskswidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.TaskProgressWidget" android:exported="false" android:label="@string/widgetprovider_taskprogresswidget_label">
|
<receiver android:name=".widget.TaskProgressWidget" android:exported="false" android:label="@string/widgetprovider_taskprogresswidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskprogresswidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskprogresswidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.CountdownWidget" android:exported="false" android:label="@string/widgetprovider_countdownwidget_label">
|
<receiver android:name=".widget.CountdownWidget" android:exported="false" android:label="@string/widgetprovider_countdownwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_countdownwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_countdownwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.BirthdaysWidget" android:exported="false" android:label="@string/widgetprovider_birthdayswidget_label">
|
<receiver android:name=".widget.BirthdaysWidget" android:exported="false" android:label="@string/widgetprovider_birthdayswidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_birthdayswidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_birthdayswidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.TodayHubWidget" android:exported="false" android:label="@string/widgetprovider_todayhubwidget_label">
|
<receiver android:name=".widget.TodayHubWidget" android:exported="false" android:label="@string/widgetprovider_todayhubwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayhubwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayhubwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.MailAndNextWidget" android:exported="false" android:label="@string/widgetprovider_mailandnextwidget_label">
|
<receiver android:name=".widget.MailAndNextWidget" android:exported="false" android:label="@string/widgetprovider_mailandnextwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailandnextwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailandnextwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.RecentFilesWidget" android:exported="false" android:label="@string/widgetprovider_recentfileswidget_label">
|
<receiver android:name=".widget.RecentFilesWidget" android:exported="false" android:label="@string/widgetprovider_recentfileswidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_recentfileswidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_recentfileswidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.AttachmentsWidget" android:exported="false" android:label="@string/widgetprovider_attachmentswidget_label">
|
<receiver android:name=".widget.AttachmentsWidget" android:exported="false" android:label="@string/widgetprovider_attachmentswidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_attachmentswidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_attachmentswidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.VacationWidget" android:exported="false" android:label="@string/widgetprovider_vacationwidget_label">
|
<receiver android:name=".widget.VacationWidget" android:exported="false" android:label="@string/widgetprovider_vacationwidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_vacationwidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_vacationwidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".widget.StorageWidget" android:exported="false" android:label="@string/widgetprovider_storagewidget_label">
|
<receiver android:name=".widget.StorageWidget" android:exported="false" android:label="@string/widgetprovider_storagewidget_label">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
<action android:name="${applicationId}.WIDGET_CLICK"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_storagewidget"/>
|
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_storagewidget"/>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,107 @@
|
|||||||
|
The reading fonts in this folder (Settings > Accessibility > Font) are
|
||||||
|
licensed under the SIL Open Font License, Version 1.1, copied below.
|
||||||
|
|
||||||
|
AtkinsonHyperlegibleNext*.ttf:
|
||||||
|
Copyright 2020-2024 The Atkinson Hyperlegible Next Project Authors
|
||||||
|
(https://github.com/googlefonts/atkinson-hyperlegible-next)
|
||||||
|
|
||||||
|
Lexend*.ttf:
|
||||||
|
Copyright 2018 The Lexend Project Authors (https://github.com/googlefonts/lexend),
|
||||||
|
with Reserved Font Name "RevReading Lexend".
|
||||||
|
|
||||||
|
OpenDyslexic*.ttf:
|
||||||
|
Copyright (c) 2019-07-29, Abbie Gonzalez (https://abbiecod.es|support@abbiecod.es),
|
||||||
|
with Reserved Font Name OpenDyslexic.
|
||||||
|
Copyright (c) 12/2012 - 2019
|
||||||
|
|
||||||
|
The files are built by scripts/build-reading-fonts.py from the woff2 files
|
||||||
|
the webmail bundles (from Fontsource, https://fontsource.org): the variable
|
||||||
|
Atkinson Hyperlegible Next and Lexend fonts as static regular and bold
|
||||||
|
instances, OpenDyslexic unchanged apart from the woff2 decompression.
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
|
|||||||
|
package com.anonymous.bulwarkmobile
|
||||||
|
|
||||||
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
|
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Facts about this build that JS can't see on its own. `distribution` is the
|
||||||
|
* product flavor (app/build.gradle): "github" for the sideloaded APK with its
|
||||||
|
* own updater, "play" for the Google Play bundle, which must not update
|
||||||
|
* itself (lib/platform-capabilities).
|
||||||
|
*/
|
||||||
|
class BulwarkBuildModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||||
|
override fun getName() = "BulwarkBuild"
|
||||||
|
|
||||||
|
override fun getConstants(): Map<String, Any> = mapOf(
|
||||||
|
"distribution" to BuildConfig.DISTRIBUTION,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -77,6 +77,10 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
val markReadLabel = options.takeIf { it.hasKey("markReadLabel") }?.getString("markReadLabel") ?: "Mark as read"
|
val markReadLabel = options.takeIf { it.hasKey("markReadLabel") }?.getString("markReadLabel") ?: "Mark as read"
|
||||||
val deleteLabel = options.takeIf { it.hasKey("deleteLabel") }?.getString("deleteLabel") ?: "Delete"
|
val deleteLabel = options.takeIf { it.hasKey("deleteLabel") }?.getString("deleteLabel") ?: "Delete"
|
||||||
val replyLabel = options.takeIf { it.hasKey("replyLabel") }?.getString("replyLabel") ?: "Reply"
|
val replyLabel = options.takeIf { it.hasKey("replyLabel") }?.getString("replyLabel") ?: "Reply"
|
||||||
|
// Delete moves the mail to Trash where there is one: no unlock needed
|
||||||
|
// for that (#85). Without a Trash it destroys the mail, so it asks.
|
||||||
|
// Unlock stays the default for a caller that doesn't say.
|
||||||
|
val deleteNeedsUnlock = !options.hasKey("deleteNeedsUnlock") || options.getBoolean("deleteNeedsUnlock")
|
||||||
|
|
||||||
// Bitmap fetch + draw off the bridge thread so the caller doesn't
|
// Bitmap fetch + draw off the bridge thread so the caller doesn't
|
||||||
// block waiting for the favicon request.
|
// block waiting for the favicon request.
|
||||||
@@ -86,7 +90,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
postNotification(
|
postNotification(
|
||||||
notificationId, title, body, preview, largeIcon, bgColorHex,
|
notificationId, title, body, preview, largeIcon, bgColorHex,
|
||||||
emailId, threadId, subject, accountId, jmapAccountId, groupKey,
|
emailId, threadId, subject, accountId, jmapAccountId, groupKey,
|
||||||
groupTitle, markReadLabel, deleteLabel, replyLabel,
|
groupTitle, markReadLabel, deleteLabel, replyLabel, deleteNeedsUnlock,
|
||||||
)
|
)
|
||||||
if (groupKey != null) postGroupSummary(groupKey, groupTitle, bgColorHex, accountId)
|
if (groupKey != null) postGroupSummary(groupKey, groupTitle, bgColorHex, accountId)
|
||||||
promise.resolve(null)
|
promise.resolve(null)
|
||||||
@@ -122,6 +126,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
markReadLabel: String,
|
markReadLabel: String,
|
||||||
deleteLabel: String,
|
deleteLabel: String,
|
||||||
replyLabel: String,
|
replyLabel: String,
|
||||||
|
deleteNeedsUnlock: Boolean,
|
||||||
) {
|
) {
|
||||||
val ctx = reactApplicationContext
|
val ctx = reactApplicationContext
|
||||||
val intent = Intent(ctx, MainActivity::class.java).apply {
|
val intent = Intent(ctx, MainActivity::class.java).apply {
|
||||||
@@ -148,8 +153,10 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val builder = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
|
val builder = NotificationCompat.Builder(ctx, BulwarkMessagingService.channelId(ctx))
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
// Android 7 has no channels: the notification names its sound.
|
||||||
|
.setSound(BulwarkMessagingService.soundUri(ctx))
|
||||||
.setLargeIcon(largeIcon)
|
.setLargeIcon(largeIcon)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
// body is the subject with the "(No subject)" fallback; an empty
|
// body is the subject with the "(No subject)" fallback; an empty
|
||||||
@@ -209,8 +216,8 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
)
|
)
|
||||||
builder.addAction(
|
builder.addAction(
|
||||||
NotificationCompat.Action.Builder(0, deleteLabel, deletePending)
|
NotificationCompat.Action.Builder(0, deleteLabel, deletePending)
|
||||||
// Android 12+: unlock first, so a locked phone can't delete mail.
|
// Android 12+: unlock first, so a locked phone can't destroy mail.
|
||||||
.setAuthenticationRequired(true)
|
.setAuthenticationRequired(deleteNeedsUnlock)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -279,7 +286,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
intent,
|
intent,
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
val summary = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
|
val summary = NotificationCompat.Builder(ctx, BulwarkMessagingService.channelId(ctx))
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
.setContentTitle(groupTitle)
|
.setContentTitle(groupTitle)
|
||||||
.setContentText(if (count == 1) "1 new message" else "$count new messages")
|
.setContentText(if (count == 1) "1 new message" else "$count new messages")
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ class BulwarkFcmPackage : ReactPackage {
|
|||||||
add(BulwarkClientCertModule(reactContext))
|
add(BulwarkClientCertModule(reactContext))
|
||||||
add(BulwarkDeviceSyncModule(reactContext))
|
add(BulwarkDeviceSyncModule(reactContext))
|
||||||
add(BulwarkWidgetsModule(reactContext))
|
add(BulwarkWidgetsModule(reactContext))
|
||||||
|
add(BulwarkPdfModule(reactContext))
|
||||||
|
add(BulwarkBuildModule(reactContext))
|
||||||
// The bridge registers React Native's own module under this name.
|
// The bridge registers React Native's own module under this name.
|
||||||
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
||||||
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
|
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import android.app.NotificationManager
|
|||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
@@ -49,7 +51,10 @@ class BulwarkMessagingService : FirebaseMessagingService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val CHANNEL_ID = "bulwark_mail"
|
// Plays the Involved sound; see channelId().
|
||||||
|
const val CHANNEL_ID = "bulwark_mail_involved"
|
||||||
|
// The first mail channel, with the system's default sound.
|
||||||
|
const val LEGACY_CHANNEL_ID = "bulwark_mail"
|
||||||
private const val TAG = "BulwarkPush"
|
private const val TAG = "BulwarkPush"
|
||||||
private const val START_WAKE_LOCK_MS = 10_000L
|
private const val START_WAKE_LOCK_MS = 10_000L
|
||||||
|
|
||||||
@@ -103,10 +108,39 @@ class BulwarkMessagingService : FirebaseMessagingService() {
|
|||||||
startWakeLock = null
|
startWakeLock = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The channel new-mail notifications go to. A channel's sound can't
|
||||||
|
* change after it is created, so the Involved sound (the webmail's
|
||||||
|
* default, lib/notification-sound.ts) needs a channel of its own; the
|
||||||
|
* old one stays as the user left it. Someone who turned mail
|
||||||
|
* notifications off in the old channel keeps them off: their
|
||||||
|
* notifications keep going there.
|
||||||
|
*/
|
||||||
|
fun channelId(context: Context): String {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return CHANNEL_ID
|
||||||
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
val legacy = manager.getNotificationChannel(LEGACY_CHANNEL_ID)
|
||||||
|
return if (legacy != null && legacy.importance == NotificationManager.IMPORTANCE_NONE) {
|
||||||
|
LEGACY_CHANNEL_ID
|
||||||
|
} else {
|
||||||
|
CHANNEL_ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Involved sound (res/raw, kept from the shrinker by res/raw/keep.xml). */
|
||||||
|
fun soundUri(context: Context): Uri =
|
||||||
|
Uri.parse("android.resource://${context.packageName}/raw/involved_notification")
|
||||||
|
|
||||||
fun ensureChannel(context: Context) {
|
fun ensureChannel(context: Context) {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (channelId(context) != CHANNEL_ID) return
|
||||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||||
|
val sound = soundUri(context)
|
||||||
|
val attributes = AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||||
|
.build()
|
||||||
val channel = NotificationChannel(
|
val channel = NotificationChannel(
|
||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
"Mail notifications",
|
"Mail notifications",
|
||||||
@@ -114,6 +148,7 @@ class BulwarkMessagingService : FirebaseMessagingService() {
|
|||||||
).apply {
|
).apply {
|
||||||
description = "Incoming email alerts"
|
description = "Incoming email alerts"
|
||||||
enableVibration(true)
|
enableVibration(true)
|
||||||
|
setSound(sound, attributes)
|
||||||
}
|
}
|
||||||
manager.createNotificationChannel(channel)
|
manager.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package com.anonymous.bulwarkmobile
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.pdf.PdfRenderer
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import com.facebook.react.bridge.Arguments
|
||||||
|
import com.facebook.react.bridge.Promise
|
||||||
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
|
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||||
|
import com.facebook.react.bridge.ReactMethod
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders PDF pages to PNG files for the in-app file preview, with the
|
||||||
|
* platform's own PdfRenderer: Android has no PDF view, and its WebView
|
||||||
|
* doesn't show PDFs either. The preview draws the pages in a WebView of
|
||||||
|
* its own so they zoom, asking for each page as it scrolls into view.
|
||||||
|
*
|
||||||
|
* PdfRenderer is not thread-safe and allows one open page at a time, so
|
||||||
|
* every call runs on one background thread, and the open document is kept
|
||||||
|
* between calls for the file the preview shows.
|
||||||
|
*/
|
||||||
|
class BulwarkPdfModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||||
|
|
||||||
|
private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "BulwarkPdf").apply { isDaemon = true } }
|
||||||
|
private var openPath: String? = null
|
||||||
|
private var descriptor: ParcelFileDescriptor? = null
|
||||||
|
private var renderer: PdfRenderer? = null
|
||||||
|
|
||||||
|
override fun getName(): String = "BulwarkPdf"
|
||||||
|
|
||||||
|
override fun invalidate() {
|
||||||
|
worker.execute { closeDocument() }
|
||||||
|
worker.shutdown()
|
||||||
|
super.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page count and each page's size in points: `{ pages: [{ width, height }] }`. */
|
||||||
|
@ReactMethod
|
||||||
|
fun open(fileUri: String, promise: Promise) {
|
||||||
|
worker.execute {
|
||||||
|
try {
|
||||||
|
val doc = document(fileUri)
|
||||||
|
val pages = Arguments.createArray()
|
||||||
|
for (i in 0 until doc.pageCount) {
|
||||||
|
doc.openPage(i).use { page ->
|
||||||
|
pages.pushMap(Arguments.createMap().apply {
|
||||||
|
putInt("width", page.width)
|
||||||
|
putInt("height", page.height)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
promise.resolve(Arguments.createMap().apply { putArray("pages", pages) })
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// Password-protected; PdfRenderer can't open those.
|
||||||
|
closeDocument()
|
||||||
|
promise.reject("E_PDF_PROTECTED", e.message, e)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
closeDocument()
|
||||||
|
promise.reject("E_PDF_OPEN", e.message, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render one page `width` pixels wide into `outUri` (a PNG). Resolves with the file URI. */
|
||||||
|
@ReactMethod
|
||||||
|
fun renderPage(fileUri: String, index: Int, width: Int, outUri: String, promise: Promise) {
|
||||||
|
worker.execute {
|
||||||
|
try {
|
||||||
|
val doc = document(fileUri)
|
||||||
|
if (index < 0 || index >= doc.pageCount) throw IndexOutOfBoundsException("page $index")
|
||||||
|
doc.openPage(index).use { page ->
|
||||||
|
val w = width.coerceIn(1, MAX_PAGE_PX)
|
||||||
|
val h = (w.toLong() * page.height / page.width.coerceAtLeast(1)).toInt().coerceIn(1, MAX_PAGE_PX * 2)
|
||||||
|
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||||
|
try {
|
||||||
|
// Pages are transparent where nothing is drawn.
|
||||||
|
bitmap.eraseColor(Color.WHITE)
|
||||||
|
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||||
|
val out = fileOf(outUri)
|
||||||
|
// The preview's folder is gone once it closed; a page
|
||||||
|
// still queued then must not bring it back.
|
||||||
|
if (out.parentFile?.isDirectory != true) throw IllegalStateException("preview closed")
|
||||||
|
FileOutputStream(out).use { stream -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) }
|
||||||
|
} finally {
|
||||||
|
bitmap.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
promise.resolve(outUri)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
promise.reject("E_PDF_RENDER", e.message, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the document the preview showed. */
|
||||||
|
@ReactMethod
|
||||||
|
fun close(fileUri: String, promise: Promise) {
|
||||||
|
worker.execute {
|
||||||
|
if (openPath == fileOf(fileUri).absolutePath) closeDocument()
|
||||||
|
promise.resolve(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun document(fileUri: String): PdfRenderer {
|
||||||
|
val path = fileOf(fileUri).absolutePath
|
||||||
|
renderer?.let { if (openPath == path) return it }
|
||||||
|
closeDocument()
|
||||||
|
val fd = ParcelFileDescriptor.open(File(path), ParcelFileDescriptor.MODE_READ_ONLY)
|
||||||
|
try {
|
||||||
|
val doc = PdfRenderer(fd)
|
||||||
|
descriptor = fd
|
||||||
|
renderer = doc
|
||||||
|
openPath = path
|
||||||
|
return doc
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
fd.close()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeDocument() {
|
||||||
|
try { renderer?.close() } catch (_: Throwable) {}
|
||||||
|
try { descriptor?.close() } catch (_: Throwable) {}
|
||||||
|
renderer = null
|
||||||
|
descriptor = null
|
||||||
|
openPath = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fileOf(uri: String): File {
|
||||||
|
val path = if (uri.startsWith("file:")) Uri.parse(uri).path else uri
|
||||||
|
return File(path ?: throw IllegalArgumentException("not a file: $uri"))
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// A 4096 px page bitmap is 64 MB; keep well below.
|
||||||
|
private const val MAX_PAGE_PX = 2480
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- The calendar reminder channel (src/lib/calendar-notifications.ts) and the
|
||||||
|
mail channel (BulwarkMessagingService.kt) name their sound by resource
|
||||||
|
name, which the release resource shrinker can't see. -->
|
||||||
|
<resources xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
tools:keep="@raw/involved_notification" />
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Trust anchors for every connection the app makes (fetch, the client
|
||||||
|
certificate module, the sync adapters). Android 7+ trusts only the system
|
||||||
|
CAs unless the app opts in; a self-hosted server behind a private CA
|
||||||
|
installed under Settings > Security > Encryption & credentials would fail
|
||||||
|
with "Network request failed" otherwise (#3). Cleartext stays off, as it is
|
||||||
|
for an app targeting API 28+ without this file; the debug source set
|
||||||
|
overrides this for Metro.
|
||||||
|
-->
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="false">
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system" />
|
||||||
|
<certificates src="user" />
|
||||||
|
</trust-anchors>
|
||||||
|
</base-config>
|
||||||
|
</network-security-config>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user