Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
107eb807e3 | ||
|
|
e731357b89 | ||
|
|
9cf1d3e849 | ||
|
|
d3dcc4e830 | ||
|
|
e0d2b190d3 | ||
|
|
b048eaca03 | ||
|
|
6f515e15f7 | ||
|
|
0a176ab51c | ||
|
|
75299244cf | ||
|
|
6aaa479875 | ||
|
|
124c0258ad | ||
|
|
ccffd6b10d | ||
|
|
0d96d8d044 | ||
|
|
95c31631fb | ||
|
|
cf7275c9c3 | ||
|
|
e452f72a8a | ||
|
|
776447dd41 | ||
|
|
52eb18dc33 | ||
|
|
33dc3cd0dc | ||
|
|
72b4f4abac | ||
|
|
de1b955bfd | ||
|
|
c8434b0610 | ||
|
|
e19229a5c6 | ||
|
|
d7cee7ca78 | ||
|
|
b59d00f0b7 | ||
|
|
975ed08c82 | ||
|
|
76170648f5 | ||
|
|
509583e729 | ||
|
|
7b93265b4f | ||
|
|
b5c4d62bde | ||
|
|
c6cb44db61 | ||
|
|
0db4bf00aa | ||
|
|
e94b782f8e | ||
|
|
ae1ef5ba5e | ||
|
|
f891bf3abb | ||
|
|
af55e0acef | ||
|
|
1ebc5c66f7 | ||
|
|
0b3710265b | ||
|
|
56e1593646 | ||
|
|
e7857ef806 | ||
|
|
b3290c3202 | ||
|
|
f231d1c5c6 | ||
|
|
984c359bb4 | ||
|
|
50ab7145c4 | ||
|
|
8f68d24bdb | ||
|
|
f22e851c82 | ||
|
|
d4982f6932 | ||
|
|
275d674577 | ||
|
|
0460da51e5 | ||
|
|
90170ea0e7 | ||
|
|
cbc41c963b | ||
|
|
63eefd7700 | ||
|
|
d97571cf0f | ||
|
|
8403843e76 | ||
|
|
756ceaa436 | ||
|
|
99de74de93 | ||
|
|
46d30169b2 | ||
|
|
512a28c494 | ||
|
|
29c172f2af | ||
|
|
9e463d3a66 | ||
|
|
ab712f11ba | ||
|
|
053fde8d56 | ||
|
|
250c0a9c26 | ||
|
|
d62805f805 | ||
|
|
b9e6ba417d | ||
|
|
0087e4c8ff | ||
|
|
2470e99dc8 | ||
|
|
8ab82cdeb7 | ||
|
|
64a62b6306 | ||
|
|
f4ff07da7a | ||
|
|
2288d28e46 | ||
|
|
295e292e47 | ||
|
|
802474d3f2 | ||
|
|
bfa02f1177 | ||
|
|
4b29cdfd3c | ||
|
|
2a47913d70 | ||
|
|
e19885dc72 | ||
|
|
4816f0e08b | ||
|
|
ef1d4bc387 | ||
|
|
61690603a2 | ||
|
|
765ff26aa5 | ||
|
|
b832ac0c49 | ||
|
|
f53cee7ec9 | ||
|
|
21d469f7e8 | ||
|
|
c1e0cb36c9 | ||
|
|
d8879617ed | ||
|
|
7b62755aee | ||
|
|
96e1cc1c3f | ||
|
|
ec137e931b | ||
|
|
cf78b07939 | ||
|
|
5237d9004d | ||
|
|
be15af5d4c | ||
|
|
90fe43882a | ||
|
|
465eeffd1e | ||
|
|
200404483c | ||
|
|
28f6787279 | ||
|
|
89f0524ad4 | ||
|
|
172110f7b0 | ||
|
|
aa612e6f0e | ||
|
|
76180b332d | ||
|
|
f5c491f6d7 | ||
|
|
23af324d79 | ||
|
|
761c35918f | ||
|
|
86e33b9afe | ||
|
|
2a1265a9ac | ||
|
|
aba5c70b3e | ||
|
|
ccd8030af0 | ||
|
|
ad10d4a8aa | ||
|
|
9de8c56743 | ||
|
|
5fcf9367a6 | ||
|
|
bba0274a40 | ||
|
|
d179e1de82 | ||
|
|
63f2e07254 | ||
|
|
80b57161ca | ||
|
|
ee20f9a06d | ||
|
|
60b92e6ef4 | ||
|
|
c520e0a125 | ||
|
|
c9a0718acf | ||
|
|
c5143975db | ||
|
|
ae3121a746 | ||
|
|
2cec096040 | ||
|
|
a694751944 | ||
|
|
6c797f6e2c | ||
|
|
7fe4a1ba27 | ||
|
|
79c53e713c | ||
|
|
26d0cf3ccc | ||
|
|
bc74d3bbac | ||
|
|
80f7278a6f | ||
|
|
604843a408 | ||
|
|
1b4b185ac8 | ||
|
|
dfaf1f3e5e | ||
|
|
97c4f58379 | ||
|
|
5dfe869acb | ||
|
|
ab8a911863 | ||
|
|
dca9417aee | ||
|
|
8f604bf02e | ||
|
|
cf93f68fd3 | ||
|
|
2ee056749c | ||
|
|
10edc331a5 | ||
|
|
157ea398b8 | ||
|
|
e09640cfc2 | ||
|
|
ae679a7d3d | ||
|
|
156e79b348 | ||
|
|
df3175ff72 | ||
|
|
800c4e1442 | ||
|
|
5e62cf71f4 | ||
|
|
ef96acd05f | ||
|
|
0664a31f8b | ||
|
|
217bb59d57 | ||
|
|
eebb00238c | ||
|
|
41e2bdf922 | ||
|
|
11b897177d | ||
|
|
029eba4562 | ||
|
|
66789251e3 | ||
|
|
117823f746 | ||
|
|
3ab131dbe8 | ||
|
|
9439a4ffc0 | ||
|
|
4bcca70548 | ||
|
|
0110eebb78 | ||
|
|
724c3b80ee | ||
|
|
f24d919fdb | ||
|
|
13d5d1733b | ||
|
|
84f6b5777e | ||
|
|
7453513e08 | ||
|
|
d1ae713b1f | ||
|
|
1c75da8733 | ||
|
|
1a348792c0 | ||
|
|
f24ff7da9e | ||
|
|
0faee568cc | ||
|
|
654675d90b | ||
|
|
153e85cb80 | ||
|
|
b86e0a118e | ||
|
|
1ee6bc459d | ||
|
|
fe0aaa1247 | ||
|
|
78700401a2 | ||
|
|
2e1cb58548 | ||
|
|
ecbd218c34 | ||
|
|
474eae9992 | ||
|
|
7701a67dff | ||
|
|
2e9e5bb785 | ||
|
|
fad7b30dbf | ||
|
|
34236f677b | ||
|
|
09c2ff1f67 | ||
|
|
d088dd14e9 | ||
|
|
58b74527b2 | ||
|
|
7187cab904 | ||
|
|
7dbbd1ce25 | ||
|
|
78684e2492 | ||
|
|
c635ccd586 | ||
|
|
534cb8239e | ||
|
|
6f574de0b9 | ||
|
|
d34641d1f9 | ||
|
|
00f1a66095 | ||
|
|
05e3605ec7 | ||
|
|
20effab062 | ||
|
|
c30b4a23d2 | ||
|
|
13264d6479 | ||
|
|
7a53cd5d84 | ||
|
|
2b9d19521f | ||
|
|
c777dc4089 | ||
|
|
6785276377 | ||
|
|
d3262ff387 | ||
|
|
7736b3b551 | ||
|
|
2870a59971 | ||
|
|
232e892328 | ||
|
|
49c5d05c30 | ||
|
|
d135bb2449 | ||
|
|
417ed5a326 | ||
|
|
77fd494633 | ||
|
|
9d83b2fb62 | ||
|
|
a27d014c59 | ||
|
|
26bb947940 | ||
|
|
ad0f83c72f | ||
|
|
434c554038 | ||
|
|
1c574a5a93 | ||
|
|
eb8b33160c | ||
|
|
e67e240840 | ||
|
|
a948738506 | ||
|
|
1131073323 | ||
|
|
59cfe50793 | ||
|
|
3ed5fa53af | ||
|
|
96a6b38321 | ||
|
|
f743ab0f7d | ||
|
|
95c8324f12 | ||
|
|
59fcf386b9 | ||
|
|
f66669964a | ||
|
|
ed2525e5cb | ||
|
|
1f7acaf08a | ||
|
|
db07acc37c | ||
|
|
39628d84dc | ||
|
|
78901f2375 | ||
|
|
a4985a63a8 | ||
|
|
f7ec09942d | ||
|
|
c165fef588 | ||
|
|
4052523ba9 | ||
|
|
e30749a50e | ||
|
|
53c0bc0fc2 | ||
|
|
a8e74c8b6d | ||
|
|
949bb10ac5 | ||
|
|
e5659ed5e8 | ||
|
|
a764a07c65 | ||
|
|
5ddc2df64b | ||
|
|
fef23e4c33 | ||
|
|
4a3bce154c | ||
|
|
13a3e95a0a | ||
|
|
fd55e16492 | ||
|
|
5e557266d1 | ||
|
|
b178ee8d07 | ||
|
|
1cbf763a36 | ||
|
|
84fef142b9 | ||
|
|
b3a82f1241 | ||
|
|
13cba64854 | ||
|
|
e3501f939d | ||
|
|
5e3cbf2419 | ||
|
|
9c031c8761 | ||
|
|
c81dc5179f | ||
|
|
d72aab2519 | ||
|
|
d4ac1e1df4 | ||
|
|
06ce86e31a | ||
|
|
6fe907b4bc | ||
|
|
c1e5cd6c8b | ||
|
|
455608f9f0 | ||
|
|
5801d6c97a | ||
|
|
771558d8c7 | ||
|
|
9c0c35c7ba | ||
|
|
ccdc4dee56 | ||
|
|
aab29da148 | ||
|
|
d59c571d93 | ||
|
|
cb5c152cb3 | ||
|
|
6e6fd95e5e | ||
|
|
8af163a6a3 | ||
|
|
b7f711050e | ||
|
|
9282ee6d72 | ||
|
|
a8e6f6e8c4 | ||
|
|
b7a5953255 | ||
|
|
0cee422373 | ||
|
|
644443193c | ||
|
|
d2066dad07 | ||
|
|
dd0969d48b | ||
|
|
96bffa51e8 | ||
|
|
5434656e03 | ||
|
|
de3d697aa9 | ||
|
|
caf61ece79 | ||
|
|
71522b9b92 | ||
|
|
67935d2edc | ||
|
|
d7c890869b | ||
|
|
4f3940f1d3 | ||
|
|
9c2f819eac | ||
|
|
9fa931c589 | ||
|
|
1196ad56c2 | ||
|
|
3bf2c30c99 | ||
|
|
018accd865 | ||
|
|
1c7c2a3be8 | ||
|
|
49ed48791d | ||
|
|
ef143a4dbb | ||
|
|
2a055ddfe2 | ||
|
|
cd911ada79 | ||
|
|
e0b72883ac | ||
|
|
54b169c6c7 | ||
|
|
6ebadf738f | ||
|
|
01fc6fbba5 | ||
|
|
7cdebc465a | ||
|
|
ad1f053f72 | ||
|
|
1348e7d20a | ||
|
|
ac8e3fb2e9 | ||
|
|
33e303e248 | ||
|
|
96c5a3f046 | ||
|
|
f397ee3e1b | ||
|
|
84548898c1 | ||
|
|
1a651a9c25 | ||
|
|
83db70ac86 | ||
|
|
a4ac6cad5c | ||
|
|
a7e4e84f5d | ||
|
|
e0a79ca5c6 | ||
|
|
edab181e60 | ||
|
|
d097ae9ec3 | ||
|
|
8d5bced7f3 | ||
|
|
ee50418bac | ||
|
|
ddff2f7b66 | ||
|
|
d8db4a5597 | ||
|
|
1fb60a5c61 | ||
|
|
115e162b86 | ||
|
|
a3193ebaf7 | ||
|
|
40ace883b7 | ||
|
|
9135deed80 | ||
|
|
3c7b96e11a | ||
|
|
2e78c9870c | ||
|
|
46e285b3c9 | ||
|
|
4c9efbc44d | ||
|
|
7964e646c4 | ||
|
|
008bff29f8 | ||
|
|
af6290fd4c | ||
|
|
4e6426171a | ||
|
|
b1fb2e9b22 | ||
|
|
25c088383f | ||
|
|
9f75e70218 | ||
|
|
30aea63a66 | ||
|
|
85f23dfdf4 | ||
|
|
4a5d837cd7 | ||
|
|
f2dd3a8b4b | ||
|
|
b9db3764ff | ||
|
|
82ca359df7 | ||
|
|
5bcfe1fe4d | ||
|
|
063432bf28 | ||
|
|
604eb39340 | ||
|
|
6c31c8d0c6 | ||
|
|
3f88868aad | ||
|
|
d53bfec68a | ||
|
|
4fdf39aa08 | ||
|
|
409cdb139e | ||
|
|
1d755fc066 | ||
|
|
a6ef0abc2a | ||
|
|
c0582ab950 | ||
|
|
15717f137a | ||
|
|
4b903ed179 | ||
|
|
6a975b822c | ||
|
|
1b64c14280 | ||
|
|
dcdda5ed9f | ||
|
|
548b647f15 | ||
|
|
fc00781b47 | ||
|
|
3faac98627 | ||
|
|
472c4d8f26 | ||
|
|
0ffdcacea9 | ||
|
|
2d907ffab8 | ||
|
|
a0c2bd7f4a | ||
|
|
235f4ea77f | ||
|
|
92b42e4a4f | ||
|
|
191f4f32cd | ||
|
|
7e598c5bc9 | ||
|
|
ebd8dacdfd | ||
|
|
aa250759e5 | ||
|
|
cb9a741244 | ||
|
|
695aa23563 | ||
|
|
8618abf1d7 | ||
|
|
a3d3af797c | ||
|
|
ddfe85a5b2 | ||
|
|
ccaa24ecd3 | ||
|
|
d8046a8aad | ||
|
|
9e95273c14 | ||
|
|
33d26744b6 | ||
|
|
5e42c6737e | ||
|
|
9c31de2e80 | ||
|
|
5c7301b3f1 | ||
|
|
d987930034 | ||
|
|
35e0792558 | ||
|
|
0a8686a008 | ||
|
|
668e01023c | ||
|
|
c888e95c5a | ||
|
|
e6be8922b7 | ||
|
|
182774884e | ||
|
|
a734d0ade1 | ||
|
|
95c24a4214 | ||
|
|
eca3871545 | ||
|
|
d89e33110a | ||
|
|
ac58b36a77 | ||
|
|
e6549d31da | ||
|
|
2511df1059 | ||
|
|
a86cd1d0eb | ||
|
|
f63d739fb3 | ||
|
|
72f48db4e5 | ||
|
|
4e7ae2fa30 | ||
|
|
48825e919a | ||
|
|
24849baca5 | ||
|
|
e37621be5e | ||
|
|
29169c3426 | ||
|
|
5237848312 | ||
|
|
746572c784 | ||
|
|
6f0abb4384 | ||
|
|
1d1d6a5bfc | ||
|
|
d45c7996d2 | ||
|
|
476780553c | ||
|
|
910c503f4a | ||
|
|
7531118727 | ||
|
|
55f1ec431f | ||
|
|
cb2f03bc09 | ||
|
|
50418971f4 | ||
|
|
859770b814 | ||
|
|
c51a848dfb | ||
|
|
e67947aa93 | ||
|
|
802b19916c | ||
|
|
e28e8b4d9c | ||
|
|
ee652d39df | ||
|
|
06e89422bd | ||
|
|
cd4ce9d08b | ||
|
|
8d7fbfc99c | ||
|
|
caea3d0c60 | ||
|
|
00ad2f49fb | ||
|
|
4eb8c47670 | ||
|
|
bd42206686 | ||
|
|
ab845783ab | ||
|
|
5f3f584ca8 | ||
|
|
cb6ddd6fcf | ||
|
|
ee04e95d4f | ||
|
|
bd1b2f3fd3 | ||
|
|
7c4b584d9c | ||
|
|
e35cd82f5f | ||
|
|
5b8c4353d8 | ||
|
|
c2584c2bd5 | ||
|
|
6c82130267 | ||
|
|
f228257ec9 | ||
|
|
a02e7f8a49 | ||
|
|
ea1f53f550 | ||
|
|
4e035f3011 | ||
|
|
e04bea451a | ||
|
|
5cb28f5d5d | ||
|
|
94c9a2e7a2 | ||
|
|
3f2dfe83ee | ||
|
|
99a20777b7 | ||
|
|
96272b6e17 | ||
|
|
8e33808ded | ||
|
|
2abea9742e | ||
|
|
c705227611 | ||
|
|
5fe8e407c7 | ||
|
|
0159d6acc8 | ||
|
|
8fd709d308 | ||
|
|
0205aa0e1c | ||
|
|
09c24fa805 | ||
|
|
86c3ab9f28 | ||
|
|
9398b46795 | ||
|
|
f9ae4423e9 | ||
|
|
8e1d03c746 | ||
|
|
a222e89611 | ||
|
|
26786ca3e4 | ||
|
|
6ae21d5690 | ||
|
|
d6670c2b60 | ||
|
|
d2ed27fd3b | ||
|
|
ee3b3ffcc3 | ||
|
|
0f80e98fbc | ||
|
|
2b44936a17 | ||
|
|
bf92a00199 | ||
|
|
00fd0696b2 | ||
|
|
c659b5489c | ||
|
|
3a8264c7f7 | ||
|
|
dc3ac4c7ed | ||
|
|
ae31a71e01 | ||
|
|
0e63d45599 | ||
|
|
22697fd111 | ||
|
|
fe45a444d3 | ||
|
|
bea3fc4c42 | ||
|
|
5b72c4d314 | ||
|
|
3194b5ece1 | ||
|
|
f07e039ef0 | ||
|
|
8206893860 | ||
|
|
65c73d82a1 | ||
|
|
1d1ff35d1b | ||
|
|
18e56163de | ||
|
|
3ae9bbe702 | ||
|
|
5802caeea9 | ||
|
|
06742ef7d5 | ||
|
|
dae5700688 | ||
|
|
42ab246cb9 | ||
|
|
2bf2e89813 | ||
|
|
ddae1e63f2 | ||
|
|
490355c65e | ||
|
|
1f2df5f079 | ||
|
|
2586eccaca | ||
|
|
7f9f5af7e6 | ||
|
|
78532211f3 | ||
|
|
240471764a | ||
|
|
63bf3921b0 | ||
|
|
56f8dceb8f | ||
|
|
0d94d7643c | ||
|
|
99eb525d22 | ||
|
|
58e3ef6827 | ||
|
|
4bebf70482 | ||
|
|
0e514659be | ||
|
|
78114b620a | ||
|
|
bc4c57e734 | ||
|
|
358bdb93ea | ||
|
|
8bf48f6465 | ||
|
|
431facef0b | ||
|
|
3d0d440673 | ||
|
|
8deff66274 | ||
|
|
a071d0581c | ||
|
|
6f747db858 | ||
|
|
69bedf0b26 | ||
|
|
ce97232eb2 | ||
|
|
516c9c931a | ||
|
|
c8be383c05 | ||
|
|
9ae91a6ab6 | ||
|
|
c0e1d6b0a8 | ||
|
|
a0ce9258cd | ||
|
|
1cef53af5a | ||
|
|
9800ad07c2 | ||
|
|
3fd5f10afe | ||
|
|
edc26ce1ca | ||
|
|
032732cea1 | ||
|
|
b52768e6fb | ||
|
|
e70e57bf47 | ||
|
|
f6f26f7950 | ||
|
|
00faceb25f | ||
|
|
7f956d60f7 | ||
|
|
5dc40706fa | ||
|
|
26a34d5900 | ||
|
|
580e010b33 | ||
|
|
ccbe67c1bc | ||
|
|
506a4b1a84 | ||
|
|
b538a0e53d | ||
|
|
58a6f45c15 | ||
|
|
feb4affea6 | ||
|
|
3ffcef2ec1 | ||
|
|
990cd84ee3 | ||
|
|
0b1c240802 | ||
|
|
2c0dbd15d1 | ||
|
|
161baf1708 | ||
|
|
1c6aa680c5 | ||
|
|
e650cb2d0a | ||
|
|
eb1dd6c844 | ||
|
|
fb4796e188 | ||
|
|
b84d4d8e9e | ||
|
|
eaa8249e76 | ||
|
|
a63ba7cb81 | ||
|
|
beec3322b8 | ||
|
|
21bc02709a | ||
|
|
32b5298cdd | ||
|
|
da1ff62725 | ||
|
|
077b590851 | ||
|
|
71af9d105d | ||
|
|
82b97fada4 | ||
|
|
428a1bf03d | ||
|
|
09350dd1f0 | ||
|
|
d2d13a37c9 | ||
|
|
be82bfe814 | ||
|
|
5c775ad82e | ||
|
|
941e5ede12 | ||
|
|
2577f4e64b | ||
|
|
ee21c15fec | ||
|
|
e574fd9e11 | ||
|
|
dfa278f7a8 | ||
|
|
4eeda34d95 | ||
|
|
22ad291202 | ||
|
|
f040a48e2f | ||
|
|
c29cbcd549 | ||
|
|
bf67b97176 | ||
|
|
93761e67ef | ||
|
|
a2484f3508 | ||
|
|
82997e27e4 | ||
|
|
b7995fafde | ||
|
|
cde461999f | ||
|
|
802e1e1c71 | ||
|
|
2427bdbd79 | ||
|
|
05e2af23dd | ||
|
|
42f18a78b2 | ||
|
|
406d2c208a | ||
|
|
6f712bf43f | ||
|
|
844cb703c8 | ||
|
|
11e5963149 | ||
|
|
07438e010e | ||
|
|
3e33efb71f | ||
|
|
376ee3d678 | ||
|
|
6f19cdbd17 | ||
|
|
6985a7d5f6 | ||
|
|
8231a55acb | ||
|
|
1b0740eab4 | ||
|
|
c5e509274d | ||
|
|
1bf5920b04 | ||
|
|
2547c6f199 | ||
|
|
69e27a386c | ||
|
|
7390096567 | ||
|
|
58c642f27b | ||
|
|
b1d1e84666 | ||
|
|
55484453a1 | ||
|
|
b525c52870 | ||
|
|
b64afb462f | ||
|
|
b92f85a236 | ||
|
|
643d704b74 | ||
|
|
9e383c3616 | ||
|
|
1543db49d1 | ||
|
|
910e5418e9 | ||
|
|
a019d9e775 | ||
|
|
e6d47b6a16 | ||
|
|
b10f61a642 | ||
|
|
752aa719d8 | ||
|
|
5884a18979 | ||
|
|
3ccc83e7ce | ||
|
|
5dc4ea1a45 | ||
|
|
1bd476e052 | ||
|
|
41793455f7 | ||
|
|
d99cbf1dbb | ||
|
|
1afd2185d5 | ||
|
|
5aa1c88b1e | ||
|
|
ac95a2bc39 | ||
|
|
e75aefeb4f | ||
|
|
70863e0923 | ||
|
|
b6ecf07369 | ||
|
|
bf5d75d5e9 | ||
|
|
53520a4236 | ||
|
|
c55f78b5a7 | ||
|
|
5d47f47c8e | ||
|
|
d16f04e818 | ||
|
|
d616943c25 | ||
|
|
cf4e3955bf | ||
|
|
bf20c0af58 | ||
|
|
67f7f83621 | ||
|
|
086d398058 | ||
|
|
373a1a60d8 | ||
|
|
f71f219187 | ||
|
|
77b7be03d8 | ||
|
|
9dd9ce8105 | ||
|
|
dd64931be8 | ||
|
|
ac234f288e | ||
|
|
81749dd89c | ||
|
|
beef086d4c | ||
|
|
cfd67398de | ||
|
|
2b15d2dc50 | ||
|
|
9bf2ecdf27 | ||
|
|
67db8a2ac3 | ||
|
|
6846d859fd | ||
|
|
3bdca0133e | ||
|
|
1858d08dd0 | ||
|
|
427d163dc0 | ||
|
|
7e34ba42ef | ||
|
|
886a64171d | ||
|
|
760bf01f4d | ||
|
|
b6d87093b9 | ||
|
|
2a3b1f90dc | ||
|
|
2ed9c29bd5 | ||
|
|
455f26e90a | ||
|
|
e9b93bc6e5 | ||
|
|
c21661ac7a | ||
|
|
00be851611 | ||
|
|
71ff8930a2 | ||
|
|
29b06a3a4f | ||
|
|
2911b8e7ac | ||
|
|
3d4779a689 | ||
|
|
641fde0505 | ||
|
|
6d5195d9fa | ||
|
|
40f9da244f | ||
|
|
c7ea4d757c | ||
|
|
1d89cb38cf | ||
|
|
abbbff2afa | ||
|
|
0a402225f1 | ||
|
|
9bfeb027de | ||
|
|
9a76b22d86 | ||
|
|
b254573237 | ||
|
|
2409a51952 | ||
|
|
37e1b93c48 | ||
|
|
a656a6b56a | ||
|
|
20cdc282c8 | ||
|
|
cec98d89db | ||
|
|
8fa8597e9f | ||
|
|
57f15f6f2e | ||
|
|
cd3fdfa217 | ||
|
|
f8064d4544 | ||
|
|
a8bb83ffe0 | ||
|
|
c37256c54e | ||
|
|
e1d318ebc1 | ||
|
|
2090480bf4 | ||
|
|
329c8a2f0b | ||
|
|
49d1fc62a8 | ||
|
|
d5a336519a | ||
|
|
9c7654acb2 | ||
|
|
346a71de84 | ||
|
|
9a278f1be3 | ||
|
|
cf3f919fa2 | ||
|
|
792b028b7a | ||
|
|
ac84f13b22 | ||
|
|
571b28a9f2 | ||
|
|
a95783723a | ||
|
|
b36e065ebe | ||
|
|
0d7bf3204a | ||
|
|
a76e0fe8c0 | ||
|
|
cee4193e58 | ||
|
|
5ac2a142b5 | ||
|
|
e73dc4e5ba | ||
|
|
892ff33f14 | ||
|
|
7e42cb85ea | ||
|
|
02c590beb3 | ||
|
|
e5bbfeb26e | ||
|
|
373c66f2ba | ||
|
|
07a18595aa | ||
|
|
2309845274 | ||
|
|
04815c4e60 |
@@ -0,0 +1,3 @@
|
||||
# patch-package patches must keep the patched files' line endings byte for byte
|
||||
# (react-native-android-widget ships CRLF sources).
|
||||
patches/*.patch -text
|
||||
@@ -0,0 +1,26 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
@@ -10,13 +10,42 @@ on:
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
# Signing is described in docs/android-release.md.
|
||||
env:
|
||||
# Certificate SHA-256 of android/app/debug.keystore, the stock React Native
|
||||
# template key (CN=Android Debug, SHA-1 5E:8F:16:06:...:F6:25). It is public,
|
||||
# so Android 9+ must never see it as the signer of a published build.
|
||||
DEBUG_CERT_SHA256: fac61745dc0903786fb9ede62a962b399f7348f0bb6f899b8332667591033b9c
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
# Fail in seconds instead of after a 20-minute build. There is no
|
||||
# unsigned or debug-signed fallback: anything signed with the debug key
|
||||
# can be "updated" by anyone.
|
||||
- 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. Release APKs are never signed with the public debug key; see docs/android-release.md."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -30,7 +59,12 @@ jobs:
|
||||
java-version: 17
|
||||
cache: gradle
|
||||
|
||||
# The action installs the legacy `tools` package by default, which the
|
||||
# runner's cmdline-tools 16 no longer carries ("Failed to find package
|
||||
# 'tools'"). Gradle fetches the platforms and build-tools it needs.
|
||||
- uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: platform-tools
|
||||
|
||||
- name: Cache Android NDK / CMake
|
||||
uses: actions/cache@v4
|
||||
@@ -42,6 +76,16 @@ jobs:
|
||||
key: android-sdk-${{ runner.os }}-${{ hashFiles('android/build.gradle', 'android/gradle.properties', 'android/app/build.gradle') }}
|
||||
restore-keys: android-sdk-${{ runner.os }}-
|
||||
|
||||
- name: Compute native cache key input
|
||||
run: |
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8'));
|
||||
if (lock.packages && lock.packages['']) delete lock.packages[''].version;
|
||||
delete lock.version;
|
||||
fs.writeFileSync('.cxx-cache-input.json', JSON.stringify(lock.packages || {}));
|
||||
"
|
||||
|
||||
- name: Cache native CMake build outputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -50,29 +94,44 @@ jobs:
|
||||
android/app/build/intermediates/cxx
|
||||
android/app/build/intermediates/merged_native_libs
|
||||
android/app/build/intermediates/stripped_native_libs
|
||||
key: cxx-${{ runner.os }}-${{ hashFiles('package-lock.json', 'android/build.gradle', 'android/app/build.gradle') }}
|
||||
key: cxx-${{ runner.os }}-${{ hashFiles('.cxx-cache-input.json', 'android/build.gradle', 'android/app/build.gradle') }}
|
||||
restore-keys: cxx-${{ runner.os }}-
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Decode release keystore
|
||||
id: keystore
|
||||
# Checks the password and alias before the long build, and refuses a
|
||||
# debug key that was uploaded as the release key by mistake.
|
||||
- name: Decode and check the release key
|
||||
id: key
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
run: |
|
||||
if [ -n "$KEYSTORE_BASE64" ]; then
|
||||
echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore"
|
||||
echo "path=$RUNNER_TEMP/release.keystore" >> "$GITHUB_OUTPUT"
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||
set -euo pipefail
|
||||
KEYSTORE="$RUNNER_TEMP/release.keystore"
|
||||
CERT="$RUNNER_TEMP/release-cert.der"
|
||||
echo "$KEYSTORE_BASE64" | base64 -d > "$KEYSTORE"
|
||||
if ! keytool -exportcert -keystore "$KEYSTORE" -alias "$KEY_ALIAS" \
|
||||
-storepass:env KEYSTORE_PASSWORD -file "$CERT"; then
|
||||
echo "::error title=Release key unreadable::Could not read alias ANDROID_KEY_ALIAS from ANDROID_KEYSTORE_BASE64 with ANDROID_KEYSTORE_PASSWORD."
|
||||
exit 1
|
||||
fi
|
||||
CERT_SHA256=$(sha256sum "$CERT" | cut -d' ' -f1)
|
||||
OWNER=$(keytool -printcert -file "$CERT")
|
||||
if [ "$CERT_SHA256" = "$DEBUG_CERT_SHA256" ] || [[ "$OWNER" == *"CN=Android Debug"* ]]; then
|
||||
echo "::error title=Release key is a debug key::ANDROID_KEYSTORE_BASE64 holds an Android debug key. Generate a release key as described in docs/android-release.md."
|
||||
exit 1
|
||||
fi
|
||||
echo "Release certificate SHA-256: $CERT_SHA256"
|
||||
echo "store_file=$KEYSTORE" >> "$GITHUB_OUTPUT"
|
||||
echo "cert_sha256=$CERT_SHA256" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build release APK
|
||||
working-directory: android
|
||||
env:
|
||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_STORE_FILE: ${{ steps.keystore.outputs.path }}
|
||||
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 }}
|
||||
@@ -80,15 +139,147 @@ jobs:
|
||||
chmod +x ./gradlew
|
||||
./gradlew assembleRelease --no-daemon
|
||||
|
||||
- name: Rename APK with version
|
||||
- name: Put apksigner on the PATH
|
||||
run: |
|
||||
BUILD_TOOLS=$(ls -d "$ANDROID_HOME"/build-tools/*/ | sort -V | tail -n 1)
|
||||
echo "Using $BUILD_TOOLS"
|
||||
echo "$BUILD_TOOLS" >> "$GITHUB_PATH"
|
||||
|
||||
# Optional v3 key rotation away from the debug key that signed every
|
||||
# release up to 0.1.62. Gradle signed the APK with the release key only,
|
||||
# which existing installs reject as a different app. With a lineage the
|
||||
# APK is re-signed so that Android 9+ sees the release key plus proof
|
||||
# that the debug key handed over to it, while Android 7-8.1 (v2 only)
|
||||
# still see the debug key. docs/android-release.md has the details.
|
||||
- name: Apply the signing key rotation
|
||||
id: rotation
|
||||
env:
|
||||
LINEAGE_BASE64: ${{ secrets.ANDROID_SIGNING_LINEAGE_BASE64 }}
|
||||
KEYSTORE: ${{ steps.key.outputs.store_file }}
|
||||
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
RELEASE_CERT_SHA256: ${{ steps.key.outputs.cert_sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$LINEAGE_BASE64" ]; then
|
||||
echo "No ANDROID_SIGNING_LINEAGE_BASE64: keeping the release-key-only signature."
|
||||
echo "rotated=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
LINEAGE="$RUNNER_TEMP/signing.lineage"
|
||||
echo "$LINEAGE_BASE64" | base64 -d > "$LINEAGE"
|
||||
apksigner lineage --in "$LINEAGE" --print-certs | tee "$RUNNER_TEMP/lineage.txt"
|
||||
|
||||
DIGESTS=$(sed -n 's/^Signer #[0-9]* in lineage certificate SHA-256 digest: //p' "$RUNNER_TEMP/lineage.txt")
|
||||
if [ "$(sed -n '1p' <<< "$DIGESTS")" != "$DEBUG_CERT_SHA256" ] \
|
||||
|| [ "$(sed -n '$p' <<< "$DIGESTS")" != "$RELEASE_CERT_SHA256" ]; then
|
||||
echo "::error title=Wrong signing lineage::ANDROID_SIGNING_LINEAGE_BASE64 must lead from the debug key to the key in ANDROID_KEYSTORE_BASE64."
|
||||
exit 1
|
||||
fi
|
||||
# Rollback on an old signer lets anything signed with it take the app
|
||||
# back after the rotation, which would undo the whole point.
|
||||
PAST_ROLLBACK=$(grep 'Has rollback capability' "$RUNNER_TEMP/lineage.txt" | sed -e 's/.*: *//' -e '$d')
|
||||
if [[ "$PAST_ROLLBACK" == *true* ]]; then
|
||||
echo "::error title=Unsafe signing lineage::An old signer in the lineage has the rollback capability. Recreate it with --set-rollback false."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Oldest signer first: apksigner uses it for v2, and the rotated key
|
||||
# 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.
|
||||
APK=android/app/build/outputs/apk/release/app-release.apk
|
||||
apksigner sign \
|
||||
--v4-signing-enabled false \
|
||||
--ks android/app/debug.keystore --ks-key-alias androiddebugkey \
|
||||
--ks-pass pass:android --key-pass pass:android \
|
||||
--next-signer \
|
||||
--ks "$KEYSTORE" --ks-key-alias "$KEY_ALIAS" \
|
||||
--ks-pass env:KEYSTORE_PASSWORD --key-pass env:KEY_PASSWORD \
|
||||
--lineage "$LINEAGE" \
|
||||
--rotation-min-sdk-version 28 \
|
||||
--out "$RUNNER_TEMP/app-release-rotated.apk" \
|
||||
"$APK"
|
||||
mv "$RUNNER_TEMP/app-release-rotated.apk" "$APK"
|
||||
echo "rotated=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The last gate before anything is uploaded.
|
||||
- name: Verify the APK signature
|
||||
env:
|
||||
RELEASE_CERT_SHA256: ${{ steps.key.outputs.cert_sha256 }}
|
||||
ROTATED: ${{ steps.rotation.outputs.rotated }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APK=android/app/build/outputs/apk/release/app-release.apk
|
||||
apksigner verify --verbose --print-certs "$APK"
|
||||
# build-tools 36 prints "Signer #1 certificate SHA-256 digest: …",
|
||||
# 37 prints "V3.0 Signer: certificate …" and "V2 Signer: …".
|
||||
signers() {
|
||||
apksigner verify --print-certs "$@" "$APK" \
|
||||
| sed -n -E 's/^(Signer #[0-9]+|V[0-9.]+ Signer:) certificate SHA-256 digest: //p' \
|
||||
| sort -u
|
||||
}
|
||||
MODERN=$(signers --min-sdk-version 28)
|
||||
LEGACY=$(signers --max-sdk-version 27)
|
||||
echo "Signer on Android 9+: $MODERN"
|
||||
echo "Signer on Android 7-8.1: $LEGACY"
|
||||
|
||||
if [[ "$MODERN" == *"$DEBUG_CERT_SHA256"* ]]; then
|
||||
echo "::error title=APK is debug-signed::Android 9+ would see the public debug key as the signer."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$MODERN" != "$RELEASE_CERT_SHA256" ]; then
|
||||
echo "::error title=Wrong APK signer::Android 9+ would see '$MODERN', expected only the release key $RELEASE_CERT_SHA256."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$ROTATED" = "true" ]; then
|
||||
echo "::warning title=Android 7-8.1 still trust the debug key::The v2 signature is made with the debug key so that installs on Android 7-8.1 can update. Those devices remain exposed; see docs/android-release.md."
|
||||
elif [ "$LEGACY" != "$RELEASE_CERT_SHA256" ]; then
|
||||
echo "::error title=Wrong APK signer::Android 7-8.1 would see '$LEGACY', expected only the release key $RELEASE_CERT_SHA256."
|
||||
exit 1
|
||||
fi
|
||||
aapt2 dump badging "$APK" | grep '^package:' || true
|
||||
|
||||
# The R8 mapping goes out with the APK: without the one from this exact
|
||||
# build, the obfuscated frames in a release stack trace cannot be
|
||||
# decoded (docs/android-release.md, "Stack traces").
|
||||
- name: Rename APK and R8 mapping with version
|
||||
id: artifact
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||
SRC=android/app/build/outputs/apk/release/app-release.apk
|
||||
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.apk"
|
||||
cp "$SRC" "$DEST"
|
||||
sha256sum "$DEST" > "$DEST.sha256"
|
||||
|
||||
MAPPING_SRC=android/app/build/outputs/mapping/release/mapping.txt
|
||||
MAPPING=""
|
||||
if [ -f "$MAPPING_SRC" ]; then
|
||||
MAPPING="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}-mapping.txt"
|
||||
cp "$MAPPING_SRC" "$MAPPING"
|
||||
elif grep -Eq '^android\.enableMinifyInReleaseBuilds=true\s*$' android/gradle.properties; then
|
||||
echo "::error title=R8 mapping missing::R8 is on but the build wrote no $MAPPING_SRC, so stack traces from this APK could not be decoded."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "path=$DEST" >> "$GITHUB_OUTPUT"
|
||||
echo "mapping=$MAPPING" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "files<<FILES_EOF"
|
||||
echo "$DEST"
|
||||
echo "$DEST.sha256"
|
||||
if [ -n "$MAPPING" ]; then echo "$MAPPING"; fi
|
||||
echo "FILES_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Ties the APK to this commit and workflow run, which the signing key
|
||||
# alone does not: `gh attestation verify <apk> --repo bulwarkmail/native`.
|
||||
- name: Attest build provenance
|
||||
uses: actions/attest@v4
|
||||
with:
|
||||
subject-path: ${{ steps.artifact.outputs.path }}
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -96,6 +287,13 @@ jobs:
|
||||
path: ${{ steps.artifact.outputs.path }}
|
||||
if-no-files-found: error
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: steps.artifact.outputs.mapping != ''
|
||||
with:
|
||||
name: ${{ steps.artifact.outputs.mapping }}
|
||||
path: ${{ steps.artifact.outputs.mapping }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Resolve target release tag
|
||||
id: target_tag
|
||||
env:
|
||||
@@ -115,9 +313,9 @@ jobs:
|
||||
echo "No release tag resolved - APK is in workflow artifacts only."
|
||||
fi
|
||||
|
||||
- name: Attach APK to release
|
||||
- name: Attach APK and R8 mapping to release
|
||||
if: steps.target_tag.outputs.tag != ''
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.target_tag.outputs.tag }}
|
||||
files: ${{ steps.artifact.outputs.path }}
|
||||
files: ${{ steps.artifact.outputs.files }}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
name: Release iOS (TestFlight)
|
||||
|
||||
# The iOS project is NOT committed - unlike android/, which is hand-maintained,
|
||||
# ios/ is gitignored and generated fresh by `expo prebuild` on every run. Keep
|
||||
# any native iOS change in app.config.js (or a config plugin), never in ios/.
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
upload_to_testflight:
|
||||
description: "Upload the build to TestFlight (off = build the IPA only)"
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
concurrency:
|
||||
group: release-ios-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
version: ${{ steps.export.outputs.version }}
|
||||
uploaded: ${{ steps.upload.outputs.done }}
|
||||
env:
|
||||
EXPO_NO_TELEMETRY: "1"
|
||||
# App Store Connect refuses a CFBundleVersion it has already seen for this
|
||||
# marketing version, so every run needs a fresh, increasing number.
|
||||
IOS_BUILD_NUMBER: ${{ github.run_number }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Show toolchain versions
|
||||
run: |
|
||||
xcodebuild -version
|
||||
pod --version
|
||||
|
||||
- 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
|
||||
run: npx expo prebuild --platform ios --clean
|
||||
|
||||
- name: Check for signing credentials
|
||||
id: creds
|
||||
env:
|
||||
CERT: ${{ secrets.IOS_DIST_CERT_BASE64 }}
|
||||
ASC_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
|
||||
TEAM: ${{ secrets.APPLE_TEAM_ID }}
|
||||
PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}
|
||||
run: |
|
||||
if [ -n "$CERT" ] && [ -n "$ASC_KEY" ] && [ -n "$TEAM" ] && [ -n "$PROFILE" ]; then
|
||||
echo "signed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "signed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Signing secrets are not configured - compiling without signing. No IPA will be produced."
|
||||
fi
|
||||
|
||||
- name: Resolve workspace and scheme
|
||||
id: xcode
|
||||
run: |
|
||||
WORKSPACE=$(ls -d ios/*.xcworkspace | head -1)
|
||||
SCHEME=$(basename "$WORKSPACE" .xcworkspace)
|
||||
echo "workspace=$WORKSPACE" >> "$GITHUB_OUTPUT"
|
||||
echo "scheme=$SCHEME" >> "$GITHUB_OUTPUT"
|
||||
echo "Using workspace $WORKSPACE, scheme $SCHEME"
|
||||
|
||||
# ── Unsigned path: a compile check only, so the workflow is still useful
|
||||
# on forks and before the Apple secrets exist.
|
||||
- name: Build without signing
|
||||
if: steps.creds.outputs.signed != 'true'
|
||||
run: |
|
||||
xcodebuild build \
|
||||
-workspace "${{ steps.xcode.outputs.workspace }}" \
|
||||
-scheme "${{ steps.xcode.outputs.scheme }}" \
|
||||
-configuration Release \
|
||||
-destination 'generic/platform=iOS' \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
CODE_SIGN_IDENTITY=""
|
||||
|
||||
# ── Signed path ────────────────────────────────────────────
|
||||
- name: Import the distribution certificate
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
env:
|
||||
CERT_BASE64: ${{ secrets.IOS_DIST_CERT_BASE64 }}
|
||||
CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }}
|
||||
run: |
|
||||
KEYCHAIN="$RUNNER_TEMP/build.keychain-db"
|
||||
# Ephemeral runner, ephemeral keychain - this password protects nothing
|
||||
# beyond the life of the job.
|
||||
KEYCHAIN_PASSWORD=$(uuidgen)
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
||||
|
||||
echo "$CERT_BASE64" | base64 -d > "$RUNNER_TEMP/dist.p12"
|
||||
security import "$RUNNER_TEMP/dist.p12" -k "$KEYCHAIN" -P "$CERT_PASSWORD" \
|
||||
-T /usr/bin/codesign -T /usr/bin/security -f pkcs12
|
||||
rm -f "$RUNNER_TEMP/dist.p12"
|
||||
|
||||
# Without this, codesign triggers an interactive keychain prompt that
|
||||
# nothing can answer on a headless runner.
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: \
|
||||
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" > /dev/null
|
||||
|
||||
# Put our keychain first in the search list, keeping the login one so
|
||||
# Xcode can still reach Apple's intermediate certificates.
|
||||
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | sed 's/"//g')
|
||||
security find-identity -v -p codesigning "$KEYCHAIN"
|
||||
|
||||
- name: Install the App Store Connect API key
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
env:
|
||||
ASC_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
|
||||
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
|
||||
run: |
|
||||
# altool looks the key up by ID in this directory.
|
||||
mkdir -p ~/private_keys
|
||||
echo "$ASC_KEY_BASE64" | base64 -d > ~/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
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
env:
|
||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
# Manual signing, deliberately. Automatic signing always signs the
|
||||
# archive with a *development* identity and relies on -exportArchive to
|
||||
# re-sign it for distribution afterwards - which needs a development
|
||||
# certificate and a registered device that a CI-only TestFlight
|
||||
# pipeline has no reason to own. Naming the App Store profile up front
|
||||
# skips that dance entirely.
|
||||
xcodebuild archive \
|
||||
-workspace "${{ steps.xcode.outputs.workspace }}" \
|
||||
-scheme "${{ steps.xcode.outputs.scheme }}" \
|
||||
-configuration Release \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
|
||||
CODE_SIGN_STYLE=Manual \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID" \
|
||||
CODE_SIGN_IDENTITY="Apple Distribution" \
|
||||
PROVISIONING_PROFILE_SPECIFIER="${{ steps.profile.outputs.uuid }}"
|
||||
|
||||
# Signing failures report a symptom ("no profiles found") rather than the
|
||||
# setting that caused it, and a re-run costs ~30 minutes. Dump the
|
||||
# resolved values instead of guessing twice.
|
||||
- name: Dump signing settings
|
||||
if: failure() && steps.creds.outputs.signed == 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "── identities in the keychain ──"
|
||||
security find-identity -v -p codesigning || true
|
||||
echo "── resolved build settings ──"
|
||||
xcodebuild -workspace "${{ steps.xcode.outputs.workspace }}" \
|
||||
-scheme "${{ steps.xcode.outputs.scheme }}" \
|
||||
-configuration Release -showBuildSettings 2>/dev/null \
|
||||
| grep -Ei 'CODE_SIGN|PROVISIONING|DEVELOPMENT_TEAM|PRODUCT_BUNDLE_ID' | sort -u || true
|
||||
|
||||
- name: Export the IPA
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
id: export
|
||||
env:
|
||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
PROFILE_NAME: ${{ steps.profile.outputs.name }}
|
||||
BUNDLE_ID: ${{ steps.profile.outputs.bundle_id }}
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/ExportOptions.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key><string>app-store</string>
|
||||
<key>teamID</key><string>${TEAM_ID}</string>
|
||||
<key>signingStyle</key><string>manual</string>
|
||||
<key>signingCertificate</key><string>Apple Distribution</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>${BUNDLE_ID}</key><string>${PROFILE_NAME}</string>
|
||||
</dict>
|
||||
<key>uploadSymbols</key><true/>
|
||||
<key>destination</key><string>export</string>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
|
||||
-exportPath "$RUNNER_TEMP/export" \
|
||||
-exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist"
|
||||
|
||||
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||
SRC=$(ls "$RUNNER_TEMP"/export/*.ipa | head -1)
|
||||
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.ipa"
|
||||
cp "$SRC" "$DEST"
|
||||
echo "path=$DEST" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
with:
|
||||
name: ${{ steps.export.outputs.path }}
|
||||
path: ${{ steps.export.outputs.path }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to TestFlight
|
||||
id: upload
|
||||
# The IPA is not attached to the GitHub release: without an Apple device
|
||||
# provisioned for it, it is not installable, and TestFlight is the only
|
||||
# distribution channel for it.
|
||||
if: >-
|
||||
steps.creds.outputs.signed == 'true' &&
|
||||
(github.event_name == 'release' || inputs.upload_to_testflight)
|
||||
env:
|
||||
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
|
||||
run: |
|
||||
IPA="${{ steps.export.outputs.path }}"
|
||||
# Validate first - it catches missing Info.plist keys and entitlement
|
||||
# mistakes in seconds, instead of via a rejection email 20 minutes later.
|
||||
xcrun altool --validate-app --type ios --file "$IPA" \
|
||||
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
|
||||
xcrun altool --upload-app --type ios --file "$IPA" \
|
||||
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
|
||||
echo "done=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Uploaded build ${IOS_BUILD_NUMBER} to App Store Connect. Processing usually takes 5-30 minutes before it appears in TestFlight."
|
||||
|
||||
# Puts the upload on the public TestFlight link (README): waits for App Store
|
||||
# Connect to process it, adds it to the external group and submits it for
|
||||
# beta review. On Linux, because most of this job is waiting.
|
||||
public-testflight:
|
||||
needs: build
|
||||
if: needs.build.outputs.uploaded == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 75
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install the App Store Connect API key
|
||||
env:
|
||||
ASC_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
|
||||
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
|
||||
run: |
|
||||
mkdir -p ~/private_keys
|
||||
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||
chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||
|
||||
- name: Add the build to the public group and submit it for beta review
|
||||
env:
|
||||
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
|
||||
run: node scripts/testflight-public.mjs "${{ needs.build.outputs.version }}" "${{ github.run_number }}"
|
||||
@@ -17,6 +17,9 @@ expo-env.d.ts
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.keystore
|
||||
!/android/app/debug.keystore
|
||||
*.lineage
|
||||
|
||||
# Metro / Expo dev-server logs
|
||||
.metro-health-check*
|
||||
|
||||
@@ -1,83 +1,284 @@
|
||||
import React from 'react';
|
||||
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
|
||||
import { ActivityIndicator, AppState, Linking, StyleSheet, Text, View } from 'react-native';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { NavigationContainer, createNavigationContainerRef } from '@react-navigation/native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useColorScheme } from 'react-native';
|
||||
import {
|
||||
DarkTheme, DefaultTheme, NavigationContainer, createNavigationContainerRef, useIsFocused,
|
||||
} from '@react-navigation/native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings } from 'lucide-react-native';
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings } from './src/components/icons';
|
||||
|
||||
import { startPushUpdates } from './src/api/push';
|
||||
import { startLiveUpdates, type LiveUpdatesHandle } from './src/api/push-stream';
|
||||
import { jmapClient } from './src/api/jmap-client';
|
||||
import type { StateChange } from './src/api/types';
|
||||
import { dispatchStateChange } from './src/lib/state-change-bus';
|
||||
import {
|
||||
startCalendarNotificationSync,
|
||||
startCalendarReminderTapHandling,
|
||||
} from './src/lib/calendar-notifications';
|
||||
import { startLivenessMonitor } from './src/lib/connection-liveness';
|
||||
import { startDeviceSyncTriggers } from './src/device-sync/app/triggers';
|
||||
import {
|
||||
setPendingCalendarOpen,
|
||||
type CalendarReminderTarget,
|
||||
} from './src/navigation/pending-calendar-open';
|
||||
import { sweepStaleExportFiles } from './src/lib/email-export';
|
||||
import { useFilterStore } from './src/stores/filter-store';
|
||||
import { useVacationStore } from './src/stores/vacation-store';
|
||||
import {
|
||||
addMessageListener,
|
||||
addNotificationTapListener,
|
||||
addTokenRefreshListener,
|
||||
getInitialNotificationTap,
|
||||
getStoredRelayBaseUrl,
|
||||
setupPushNotifications,
|
||||
notificationTapJmapAccountId,
|
||||
resyncPushNotifications,
|
||||
teardownPushNotificationsForAccount,
|
||||
type NotificationTapPayload,
|
||||
} from './src/lib/push-notifications';
|
||||
import { addUnifiedPushEndpointListener } from './src/lib/unified-push';
|
||||
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
|
||||
import ComposeScreen from './src/screens/ComposeScreen';
|
||||
import EmailThreadScreen from './src/screens/EmailThreadScreen';
|
||||
import EmailSourceScreen from './src/screens/EmailSourceScreen';
|
||||
import LoginScreen from './src/screens/LoginScreen';
|
||||
import EmailListScreen from './src/screens/EmailListScreen';
|
||||
import MailTab from './src/screens/MailTab';
|
||||
import FilesScreen from './src/screens/FilesScreen';
|
||||
import CalendarScreen from './src/screens/CalendarScreenNew';
|
||||
import ContactsScreen from './src/screens/ContactsScreenNew';
|
||||
import CalendarScreen from './src/screens/CalendarScreen';
|
||||
import ContactsTab from './src/screens/ContactsTab';
|
||||
import ContactDetailScreen from './src/screens/ContactDetailScreen';
|
||||
import ContactFormScreen from './src/screens/ContactFormScreen';
|
||||
import GroupDetailScreen from './src/screens/GroupDetailScreen';
|
||||
import SettingsScreen from './src/screens/SettingsScreenNew2';
|
||||
import SettingsScreen from './src/screens/SettingsScreen';
|
||||
import ScheduledScreen from './src/screens/ScheduledScreen';
|
||||
import UnifiedInboxScreen from './src/screens/UnifiedInboxScreen';
|
||||
import { useAccountStore } from './src/stores/account-store';
|
||||
import { useAuthStore } from './src/stores/auth-store';
|
||||
import { useCalendarStore } from './src/stores/calendar-store';
|
||||
import { useContactsStore } from './src/stores/contacts-store';
|
||||
import { useEmailStore } from './src/stores/email-store';
|
||||
import { loadDetail, prefetchMessage } from './src/lib/email-detail-cache';
|
||||
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
|
||||
import { useSettingsStore } from './src/stores/settings-store';
|
||||
import { useLocaleStore } from './src/stores/locale-store';
|
||||
import { useLayout } from './src/lib/use-layout';
|
||||
import { toast } from './src/stores/toast-store';
|
||||
import { useNetworkStore } from './src/stores/network-store';
|
||||
import { useUpdatesStore } from './src/stores/updates-store';
|
||||
import { UpdateBanner } from './src/components/UpdateBanner';
|
||||
import { colors, spacing, typography } from './src/theme/tokens';
|
||||
import { PushOnboardingPrompt } from './src/components/PushOnboardingPrompt';
|
||||
import { ToastHost } from './src/components/ToastHost';
|
||||
import { UndoSnackbar } from './src/components/UndoSnackbar';
|
||||
import { AppIconBadge } from './src/components/AppIconBadge';
|
||||
import { getEmails } from './src/api/email';
|
||||
import { signOutWidgets, startWidgetSync } from './src/widgets/sync';
|
||||
import { draftContextFromEmail } from './src/lib/draft-context';
|
||||
import {
|
||||
acceptSignInLink,
|
||||
handleDeepLink,
|
||||
parseDeepLink,
|
||||
routeParkedSignInLink,
|
||||
shareToDeepLink,
|
||||
type DeepLink,
|
||||
} from './src/navigation/linking';
|
||||
import { usePendingSignInLinkStore } from './src/navigation/pending-sign-in-link';
|
||||
import { MAX_ACCOUNTS } from './src/lib/account-utils';
|
||||
import { addShareListener, getInitialShare, shareAttachments } from './src/lib/share-intent';
|
||||
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
|
||||
import { useOfflineCacheStore } from './src/stores/offline-cache-store';
|
||||
import { useOutboxStore } from './src/stores/outbox-store';
|
||||
import { runOfflineSync } from './src/lib/offline-sync';
|
||||
import { spacing, typography, type ThemePalette } from './src/theme/tokens';
|
||||
import { useColors } from './src/theme/colors';
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
const Tab = createBottomTabNavigator<MainTabsParamList>();
|
||||
const navigationRef = createNavigationContainerRef<RootStackParamList>();
|
||||
|
||||
function navigateToNotificationTap(payload: NotificationTapPayload): void {
|
||||
// The launch URL is read once per process: Android hands the same intent back
|
||||
// on every getInitialURL(), and a sign-in link must not run twice.
|
||||
let initialSignInLinkRead = false;
|
||||
|
||||
async function navigateToNotificationTap(payload: NotificationTapPayload): Promise<void> {
|
||||
if (!navigationRef.isReady()) return;
|
||||
|
||||
// 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
|
||||
// when the notification arrived), opening EmailThread under the active
|
||||
// account would fetch the email from the wrong server and fail.
|
||||
const auth = useAuthStore.getState();
|
||||
if (payload.accountId && payload.accountId !== auth.activeAccountId) {
|
||||
const account = useAccountStore.getState().getAccountById(payload.accountId);
|
||||
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 });
|
||||
navigationRef.navigate('EmailThread', {
|
||||
emailId: payload.emailId,
|
||||
threadId: payload.threadId,
|
||||
emailIds: [payload.emailId],
|
||||
subject: payload.subject,
|
||||
// A group mailbox's message lives under another JMAP account (#839).
|
||||
jmapAccountId: notificationTapJmapAccountId(payload),
|
||||
...(payload.action === 'reply' ? { action: 'reply' as const } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function LoadingScreen({ message }: { message: string }) {
|
||||
// A tapped calendar reminder opens its event (or task) in the Calendar tab,
|
||||
// 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.
|
||||
async function openCalendarReminder(target: CalendarReminderTarget): Promise<void> {
|
||||
const auth = useAuthStore.getState();
|
||||
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);
|
||||
// On a cold start the navigator mounts right after the auth gate flips.
|
||||
for (let attempt = 0; attempt < 50 && !navigationRef.isReady(); attempt++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.navigate('MainTabs', { screen: 'Calendar' } as never);
|
||||
}
|
||||
}
|
||||
|
||||
// Deep links (bulwarkmobile://, webmail https permalinks, mailto:) and
|
||||
// Android share-sheet payloads all end up here once the navigator is ready.
|
||||
async function openDeepLink(link: DeepLink): Promise<void> {
|
||||
const opened = await handleDeepLink(link, {
|
||||
navigation: navigationRef,
|
||||
resolveThreadId: async (emailId, jmapAccountId) => {
|
||||
try {
|
||||
const [email] = await getEmails([emailId], jmapAccountId);
|
||||
return email?.threadId ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
openDraft: async (emailId, jmapAccountId) => {
|
||||
try {
|
||||
// Cache first, so a draft read before still opens offline.
|
||||
const draft = draftContextFromEmail(await loadDetail(emailId, jmapAccountId), jmapAccountId);
|
||||
if (!navigationRef.isReady()) return false;
|
||||
navigationRef.navigate('Compose', { draft });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
switchAccount: async (accountId) => {
|
||||
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
|
||||
// rather than leave the user wondering why the app just opened.
|
||||
if (!opened) toast.error(useLocaleStore.getState().t('deep_link.open_failed', "Couldn't open this"));
|
||||
}
|
||||
|
||||
// Toasts and the undo bar show on whichever stack screen is up, so a failure
|
||||
// that lands after leaving the screen it came from (a viewer action finishing
|
||||
// on the Unified Inbox or a contact) is still seen, and a reply held by the
|
||||
// undo-send delay can be undone from the viewer it was sent from. Every
|
||||
// screen carries the hosts, and only the focused one renders them, so
|
||||
// neither ever shows twice (the screen below the top one is kept live).
|
||||
// The composer gets no undo bar: its Undo would hand the open composer
|
||||
// another draft. A held send's bar shows on the screen below once it closes.
|
||||
function FocusedHosts({ undo }: { undo: boolean }) {
|
||||
if (!useIsFocused()) return null;
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
<Text style={styles.loadingText}>{message}</Text>
|
||||
<>
|
||||
{undo && <UndoSnackbar />}
|
||||
<ToastHost />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function withHosts({ route, children }: { route: { name: string }; children: React.ReactElement }) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<FocusedHosts undo={route.name !== 'Compose'} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingScreen({ message }: { message: string }) {
|
||||
const c = useColors();
|
||||
return (
|
||||
<View style={[styles.loadingContainer, { backgroundColor: c.background }]}>
|
||||
<ActivityIndicator color={c.primary} />
|
||||
<Text style={[styles.loadingText, { color: c.textSecondary }]}>{message}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParamList, 'MainTabs'>) {
|
||||
const mailboxes = useEmailStore((state) => state.mailboxes);
|
||||
const c = useColors();
|
||||
// Select the count, not the list: every mailbox fetch sets a new array, and
|
||||
// re-rendering here re-renders the mail list with it.
|
||||
const inboxUnreadCount = useEmailStore(
|
||||
(state) => state.mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0,
|
||||
);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
const inboxUnreadCount = mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0;
|
||||
const hasCalendar = useHasCalendar();
|
||||
const hasContacts = useHasContacts();
|
||||
const hasFiles = useHasFiles();
|
||||
const disabledTabStyle = { opacity: 0.4 } as const;
|
||||
const t = useLocaleStore((state) => state.t);
|
||||
// The webmail's navigation names; a tab the server lacks says so.
|
||||
const tabLabels = {
|
||||
mail: t('sidebar.mail', 'Mail'),
|
||||
calendar: t('sidebar.calendar', 'Calendar'),
|
||||
contacts: t('sidebar.contacts', 'Contacts'),
|
||||
files: t('sidebar.files', 'Files'),
|
||||
settings: t('sidebar.settings', 'Settings'),
|
||||
};
|
||||
const unavailable = (name: string) => t('sidebar.tab_unavailable', '{name} (unavailable)', { name });
|
||||
// Side by side layouts move the tabs into a rail on the left, like the
|
||||
// webmail's navigation rail.
|
||||
const { isSplit } = useLayout();
|
||||
// A bottom tab bar keeps the screens clear of the gesture bar or taskbar;
|
||||
// with the tabs in a side rail the whole tab area has to.
|
||||
const bottomInset = useSafeAreaInsets().bottom;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<View style={{ flex: 1, backgroundColor: c.background, paddingBottom: isSplit ? bottomInset : 0 }}>
|
||||
<UpdateBanner />
|
||||
<OfflineCacheBanner />
|
||||
<PushOnboardingPrompt />
|
||||
<AppIconBadge />
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
// Matches webmail horizontal NavigationRail: bg-background, border-t border-border,
|
||||
// active uses foreground (white), inactive uses muted-foreground.
|
||||
tabBarActiveTintColor: colors.text,
|
||||
tabBarInactiveTintColor: colors.textSecondary,
|
||||
tabBarStyle: {
|
||||
backgroundColor: colors.background,
|
||||
borderTopColor: colors.border,
|
||||
// Tabs out of view stop re-rendering until they are shown again.
|
||||
freezeOnBlur: true,
|
||||
tabBarPosition: isSplit ? 'left' : 'bottom',
|
||||
tabBarVariant: isSplit ? 'material' : 'uikit',
|
||||
// A narrow rail (icon over label), not a wide sidebar with labels
|
||||
// beside the icons.
|
||||
...(isSplit ? { tabBarLabelPosition: 'below-icon' as const } : null),
|
||||
tabBarActiveTintColor: c.text,
|
||||
tabBarInactiveTintColor: c.textSecondary,
|
||||
tabBarStyle: isSplit ? {
|
||||
backgroundColor: c.secondary,
|
||||
borderRightColor: c.border,
|
||||
borderRightWidth: 1,
|
||||
elevation: 0,
|
||||
shadowOpacity: 0,
|
||||
shadowColor: 'transparent',
|
||||
} : {
|
||||
backgroundColor: c.background,
|
||||
borderTopColor: c.border,
|
||||
borderTopWidth: 1,
|
||||
elevation: 0,
|
||||
shadowOpacity: 0,
|
||||
@@ -92,11 +293,12 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
<Tab.Screen
|
||||
name="Mail"
|
||||
options={{
|
||||
title: tabLabels.mail,
|
||||
tabBarIcon: ({ color, size }) => <Mail size={size} color={color} />,
|
||||
tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined,
|
||||
tabBarBadgeStyle: {
|
||||
backgroundColor: colors.error,
|
||||
color: colors.primaryForeground,
|
||||
backgroundColor: c.error,
|
||||
color: c.primaryForeground,
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
minWidth: 16,
|
||||
@@ -108,43 +310,57 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
},
|
||||
}}
|
||||
>
|
||||
{() => (
|
||||
<EmailListScreen
|
||||
onComposePress={() => navigation.navigate('Compose')}
|
||||
onEmailPress={(email) => {
|
||||
navigation.navigate('EmailThread', {
|
||||
emailId: email.id,
|
||||
threadId: email.threadId,
|
||||
subject: email.subject,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{() => <MailTab navigation={navigation} />}
|
||||
</Tab.Screen>
|
||||
<Tab.Screen
|
||||
name="Calendar"
|
||||
component={CalendarScreen}
|
||||
options={{
|
||||
title: tabLabels.calendar,
|
||||
tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />,
|
||||
tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle,
|
||||
tabBarAccessibilityLabel: hasCalendar ? tabLabels.calendar : unavailable(tabLabels.calendar),
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: (e) => {
|
||||
if (!hasCalendar) e.preventDefault();
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Contacts"
|
||||
component={ContactsScreen}
|
||||
component={ContactsTab}
|
||||
options={{
|
||||
title: tabLabels.contacts,
|
||||
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
|
||||
tabBarItemStyle: hasContacts ? undefined : disabledTabStyle,
|
||||
tabBarAccessibilityLabel: hasContacts ? tabLabels.contacts : unavailable(tabLabels.contacts),
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: (e) => {
|
||||
if (!hasContacts) e.preventDefault();
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Files"
|
||||
component={FilesScreen}
|
||||
options={{
|
||||
title: tabLabels.files,
|
||||
tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />,
|
||||
tabBarItemStyle: hasFiles ? undefined : disabledTabStyle,
|
||||
tabBarAccessibilityLabel: hasFiles ? tabLabels.files : unavailable(tabLabels.files),
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: (e) => {
|
||||
if (!hasFiles) e.preventDefault();
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Settings"
|
||||
options={{
|
||||
title: tabLabels.settings,
|
||||
tabBarIcon: ({ color, size }) => <Settings size={size} color={color} />,
|
||||
}}
|
||||
>
|
||||
@@ -160,11 +376,29 @@ export default function App() {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const client = useAuthStore((state) => state.client);
|
||||
const restoreSession = useAuthStore((state) => state.restoreSession);
|
||||
const t = useLocaleStore((state) => state.t);
|
||||
|
||||
// Resolve the user's theme preference to a concrete light/dark style for the
|
||||
// system status bar. The rest of the app's colors are still hard-coded dark
|
||||
// until the StyleSheet migration to a theme-aware `useColors` hook lands.
|
||||
const themePref = useSettingsStore((state) => state.theme);
|
||||
const systemScheme = useColorScheme();
|
||||
const resolvedScheme: 'light' | 'dark' =
|
||||
themePref === 'system' ? (systemScheme === 'light' ? 'light' : 'dark') : themePref;
|
||||
const statusBarStyle: 'light' | 'dark' = resolvedScheme === 'light' ? 'dark' : 'light';
|
||||
// React Navigation's default theme is light: without this its containers
|
||||
// paint white behind and between screens, even in dark mode.
|
||||
const background = useColors().background;
|
||||
const navigationTheme = React.useMemo(() => {
|
||||
const base = resolvedScheme === 'light' ? DefaultTheme : DarkTheme;
|
||||
return { ...base, colors: { ...base.colors, background, card: background } };
|
||||
}, [resolvedScheme, background]);
|
||||
// Persisted active account is the signal that the user was already signed
|
||||
// in on the previous launch. When present we render the main UI with the
|
||||
// cached mail list instead of the "Restoring session" spinner; the real
|
||||
// JMAP session comes up in the background.
|
||||
const hasPersistedAccount = useAccountStore((state) => state.activeAccountId != null);
|
||||
const { isSplit } = useLayout();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasRestoredSession) {
|
||||
@@ -174,8 +408,36 @@ export default function App() {
|
||||
|
||||
React.useEffect(() => {
|
||||
void useSettingsStore.getState().hydrate();
|
||||
void useLocaleStore.getState().hydrate();
|
||||
// Calendar reminders are kept scheduled from launch, not only once the
|
||||
// Calendar tab has been opened.
|
||||
startCalendarNotificationSync();
|
||||
// Device sync (Android, #34): live changes, the app's own edits and the
|
||||
// foreground ask Android to sync; accounts are checked against Android's.
|
||||
startDeviceSyncTriggers();
|
||||
return useNetworkStore.getState().init();
|
||||
}, []);
|
||||
|
||||
// Home-screen widgets follow the signed-in data; once the last account is
|
||||
// gone they are wiped so no mail stays visible on the launcher.
|
||||
React.useEffect(() => {
|
||||
if (isAuthenticated) return startWidgetSync();
|
||||
if (hasRestoredSession) void signOutWidgets();
|
||||
return undefined;
|
||||
}, [isAuthenticated, hasRestoredSession]);
|
||||
|
||||
// When the network flips back on while we're authenticated-but-offline
|
||||
// (no live JMAP session), retry the session so the user lands back on
|
||||
// live data without needing to relaunch.
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
return useNetworkStore.subscribe((state, prev) => {
|
||||
if (state.online && !prev.online && !useAuthStore.getState().session) {
|
||||
void useAuthStore.getState().retrySession();
|
||||
}
|
||||
});
|
||||
}, [isAuthenticated]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
@@ -189,12 +451,54 @@ export default function App() {
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Listen for FCM messages so the app can refresh state even when woken
|
||||
// from the background - the FirebaseMessagingService also posts the system
|
||||
// notification so the user sees it regardless of RN runtime state.
|
||||
// Offline mail cache: hydrate the cache index on launch, and kick off a
|
||||
// background sync once we have a live JMAP session and the user has the
|
||||
// feature enabled. Re-runs whenever the user changes the days window.
|
||||
const offlineCacheEnabled = useSettingsStore((s) => s.offlineCacheEnabled);
|
||||
const offlineCacheDays = useSettingsStore((s) => s.offlineCacheDays);
|
||||
const offlineCacheMaxMB = useSettingsStore((s) => s.offlineCacheMaxMB);
|
||||
const haveLiveSession = useAuthStore((s) => s.session != null);
|
||||
React.useEffect(() => {
|
||||
void useOfflineCacheStore.getState().hydrate();
|
||||
// Attachments shared out of the app linger in the cache dir; drop the
|
||||
// ones older than a day so a granted content URI can't read them forever.
|
||||
void sweepStaleExportFiles();
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
if (!offlineCacheEnabled || !haveLiveSession) return;
|
||||
// Slight delay so cold start doesn't compete with the inbox load.
|
||||
const t = setTimeout(() => {
|
||||
void runOfflineSync({ days: offlineCacheDays, maxMB: offlineCacheMaxMB });
|
||||
}, 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [offlineCacheEnabled, offlineCacheDays, offlineCacheMaxMB, haveLiveSession]);
|
||||
|
||||
// Drain the offline action queue (outbox) as soon as we have a live session,
|
||||
// and again whenever the network comes back. The flush itself no-ops when
|
||||
// there's nothing queued or the client isn't ready.
|
||||
React.useEffect(() => {
|
||||
if (!haveLiveSession) return;
|
||||
void useOutboxStore.getState().flush();
|
||||
return useNetworkStore.subscribe((state, prev) => {
|
||||
if (state.online && !prev.online) void useOutboxStore.getState().flush();
|
||||
});
|
||||
}, [haveLiveSession]);
|
||||
|
||||
// Foreground FCM messages: the Kotlin service skips the headless task while
|
||||
// the app is visible, so feed the relay's StateChange straight into the
|
||||
// stores. SSE normally beats it, but this covers the window where the SSE
|
||||
// socket is down and the poll fallback has not fired yet.
|
||||
React.useEffect(() => {
|
||||
const unsubscribe = addMessageListener((payload) => {
|
||||
console.log('[push] fcm message', payload.title);
|
||||
const raw = payload?.data?.changed;
|
||||
if (!raw) return;
|
||||
try {
|
||||
const changed = JSON.parse(raw) as Record<string, Record<string, string>>;
|
||||
if (!changed || typeof changed !== 'object') return;
|
||||
void useEmailStore.getState().handleStateChange({ '@type': 'StateChange', changed });
|
||||
} catch {
|
||||
// malformed payload - ignore
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
@@ -210,11 +514,11 @@ export default function App() {
|
||||
void (async () => {
|
||||
const initial = await getInitialNotificationTap();
|
||||
if (cancelled || !initial) return;
|
||||
navigateToNotificationTap(initial);
|
||||
await navigateToNotificationTap(initial);
|
||||
})();
|
||||
|
||||
const unsubscribe = addNotificationTapListener((payload) => {
|
||||
navigateToNotificationTap(payload);
|
||||
void navigateToNotificationTap(payload);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -223,17 +527,134 @@ export default function App() {
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Calendar reminders are local expo notifications; their taps come through
|
||||
// expo-notifications, not the native tap store used for mail push above.
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
return startCalendarReminderTapHandling((target) => {
|
||||
void openCalendarReminder(target);
|
||||
});
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Deep links and share-sheet payloads. The cold-start URL / share is read
|
||||
// once auth is restored so the target screen has credentials.
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
let cancelled = false;
|
||||
const open = (link: DeepLink | null) => {
|
||||
if (!link || cancelled) return;
|
||||
// Give the navigator a tick to mount after the auth gate flips.
|
||||
setTimeout(() => { void openDeepLink(link); }, 50);
|
||||
};
|
||||
void Linking.getInitialURL().then((url) => open(url ? parseDeepLink(url) : null));
|
||||
void getInitialShare().then((share) => {
|
||||
if (!share) return;
|
||||
const link = shareToDeepLink(share);
|
||||
if (link.kind === 'compose' && share.uris?.length) {
|
||||
navigationRef.isReady() && navigationRef.navigate('Compose', {
|
||||
prefillTo: link.to,
|
||||
prefillSubject: link.subject,
|
||||
prefillBody: link.body,
|
||||
prefillAttachments: shareAttachments(share),
|
||||
});
|
||||
return;
|
||||
}
|
||||
open(link);
|
||||
});
|
||||
const urlSub = Linking.addEventListener('url', ({ url }) => open(parseDeepLink(url)));
|
||||
const shareSub = addShareListener((share) => {
|
||||
const link = shareToDeepLink(share);
|
||||
if (link.kind === 'compose' && share.uris?.length && navigationRef.isReady()) {
|
||||
navigationRef.navigate('Compose', {
|
||||
prefillTo: link.to,
|
||||
prefillSubject: link.subject,
|
||||
prefillBody: link.body,
|
||||
prefillAttachments: shareAttachments(share),
|
||||
});
|
||||
return;
|
||||
}
|
||||
open(link);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
urlSub.remove();
|
||||
shareSub();
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Sign-in links (`bulwarkmail://pair?…` from the webmail's Link Mobile App,
|
||||
// `bulwarkmail://connect?…`) work signed out too, so unlike the links above
|
||||
// they are listened for from launch. They are parked for the login screen
|
||||
// (the signed-out one takes them as it mounts), marked as coming from
|
||||
// outside the app: any page or app can fire one, so the login screen asks
|
||||
// before it redeems or opens anything.
|
||||
React.useEffect(() => {
|
||||
if (!initialSignInLinkRead) {
|
||||
initialSignInLinkRead = true;
|
||||
void Linking.getInitialURL().then((url) => { acceptSignInLink(url); }).catch(() => undefined);
|
||||
}
|
||||
const subscription = Linking.addEventListener('url', ({ url }) => { acceptSignInLink(url); });
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
|
||||
// Signed in, a sign-in link adds an account: open Add account, whose login
|
||||
// screen takes the parked link (or says why a refused one won't be used).
|
||||
// On a cold start the navigator mounts right after the auth gate flips, so
|
||||
// wait for it like a reminder tap does.
|
||||
const signInLinkPending = usePendingSignInLinkStore((s) => s.pending !== null || s.refusal !== null);
|
||||
const addAccountForLink = isAuthenticated && signInLinkPending;
|
||||
React.useEffect(() => {
|
||||
if (!addAccountForLink) 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;
|
||||
const route = routeParkedSignInLink(useAccountStore.getState().accounts.length);
|
||||
if (route === 'account-limit') {
|
||||
// Said like the Add account buttons say it, instead of opening a
|
||||
// screen whose sign-in can only fail.
|
||||
const t = useLocaleStore.getState().t;
|
||||
toast.error(
|
||||
t('settings.account.accounts.limit', 'Maximum of {count} accounts reached.', { count: MAX_ACCOUNTS }),
|
||||
t('login.mobile.err_account_limit_detail', 'Remove an account in Settings to add another one.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (route === 'add-account') navigationRef.navigate('AddAccount');
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [addAccountForLink]);
|
||||
|
||||
// Re-register the device with the configured relay once authenticated,
|
||||
// and whenever the FCM token rotates.
|
||||
// and whenever the FCM token rotates. Honours the user's notification
|
||||
// preference - flipping it off tears down THIS account's subscription so
|
||||
// notifications stop arriving for it. Other logged-in accounts keep their
|
||||
// setups intact. An account the user turned push off for, or whose device
|
||||
// registration was revoked, stays off (see resyncPushNotifications).
|
||||
const emailNotificationsEnabled = useSettingsStore(
|
||||
(s) => s.emailNotificationsEnabled,
|
||||
);
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated || !client) return;
|
||||
|
||||
let cancelled = false;
|
||||
const doSetup = async () => {
|
||||
if (!emailNotificationsEnabled) {
|
||||
if (activeAccountId) {
|
||||
await teardownPushNotificationsForAccount(activeAccountId).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const relayBaseUrl = await getStoredRelayBaseUrl();
|
||||
if (!relayBaseUrl) return;
|
||||
try {
|
||||
await setupPushNotifications({
|
||||
await resyncPushNotifications({
|
||||
relayBaseUrl,
|
||||
accountLabel: client.username ?? undefined,
|
||||
});
|
||||
@@ -250,52 +671,129 @@ export default function App() {
|
||||
const unsubscribe = addTokenRefreshListener(() => {
|
||||
void doSetup();
|
||||
});
|
||||
// UnifiedPush equivalent of an FCM token rotation: the distributor handed
|
||||
// out a new endpoint, so re-register it with the relay.
|
||||
const unsubscribeUp = addUnifiedPushEndpointListener(() => {
|
||||
void doSetup();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
unsubscribeUp();
|
||||
};
|
||||
}, [client, isAuthenticated]);
|
||||
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
|
||||
|
||||
// Live updates (SSE with polling fallback), re-armed on every account
|
||||
// switch and every re-established session — the singleton `client` object
|
||||
// never changes identity, so it cannot be the dependency on its own.
|
||||
// Backgrounding closes the stream (no socket + server pings while the app
|
||||
// is asleep); foregrounding reconnects it and refreshes what the user is
|
||||
// looking at, since events that happened in between are gone for good.
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated || !client) {
|
||||
if (!isAuthenticated || !client || !haveLiveSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
let cleanup: (() => void) | null = null;
|
||||
let handle: LiveUpdatesHandle | null = null;
|
||||
let appActive = AppState.currentState !== 'background' && AppState.currentState !== 'inactive';
|
||||
|
||||
void (async () => {
|
||||
const onStateChange = async (change: StateChange) => {
|
||||
// Filters / vacation edited elsewhere (webmail, another device): refetch
|
||||
// so the settings panes don't save stale state over the newer script.
|
||||
const primary = (() => { try { return jmapClient.accountId; } catch { return null; } })();
|
||||
const own = primary ? change.changed?.[primary] : undefined;
|
||||
const extra: Promise<unknown>[] = [];
|
||||
if (own?.SieveScript) extra.push(useFilterStore.getState().fetchFilters().catch(() => undefined));
|
||||
if (own?.VacationResponse) extra.push(useVacationStore.getState().fetch().catch(() => undefined));
|
||||
dispatchStateChange(change);
|
||||
await Promise.all([
|
||||
useEmailStore.getState().handleStateChange(change),
|
||||
useContactsStore.getState().handleStateChange(change),
|
||||
useCalendarStore.getState().handleStateChange(change),
|
||||
...extra,
|
||||
]);
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
const stopPushUpdates = await startPushUpdates(client, {
|
||||
onStateChange: async (change) => {
|
||||
await Promise.all([
|
||||
useEmailStore.getState().handleStateChange(change),
|
||||
useContactsStore.getState().handleStateChange(change),
|
||||
useCalendarStore.getState().handleStateChange(change),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.warn(error.message);
|
||||
},
|
||||
const next = await startLiveUpdates({
|
||||
onStateChange: (change) => { void onStateChange(change); },
|
||||
onError: (error) => { console.warn('[push]', error.message); },
|
||||
onFallback: (reason) => { console.warn('[push] falling back to polling:', reason); },
|
||||
isActive: () => appActive,
|
||||
});
|
||||
|
||||
if (!mounted) {
|
||||
stopPushUpdates();
|
||||
next.close();
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup = stopPushUpdates;
|
||||
handle = next;
|
||||
} catch (error) {
|
||||
console.warn(error instanceof Error ? error.message : 'Failed to start JMAP push updates');
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const refreshAfterResume = () => {
|
||||
const email = useEmailStore.getState();
|
||||
void email.fetchMailboxes();
|
||||
if (email.currentMailboxId) void email.refreshEmails();
|
||||
void useOutboxStore.getState().flush();
|
||||
};
|
||||
|
||||
// Foreground liveness for the per-account connection dot: the open
|
||||
// stream's pings, or a `Core/echo` every 30 s while there is none (see
|
||||
// startLivenessMonitor). NetInfo cannot tell an unreachable server.
|
||||
const liveness = startLivenessMonitor({
|
||||
ping: () => jmapClient.ping(),
|
||||
streamHealthy: () => handle?.healthy ?? false,
|
||||
isActive: () => appActive,
|
||||
isOnline: () => useNetworkStore.getState().online,
|
||||
onConnected: (connected) => {
|
||||
if (!mounted || !activeAccountId) return;
|
||||
const account = useAccountStore.getState().getAccountById(activeAccountId);
|
||||
if (account && account.isConnected !== connected) {
|
||||
useAccountStore.getState().updateAccount(activeAccountId, { isConnected: connected });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
void start();
|
||||
|
||||
const subscription = AppState.addEventListener('change', (state) => {
|
||||
const nowActive = state === 'active';
|
||||
if (nowActive === appActive) return;
|
||||
appActive = nowActive;
|
||||
if (!nowActive) {
|
||||
handle?.close();
|
||||
handle = null;
|
||||
return;
|
||||
}
|
||||
// Coming back: reconnect and catch up on what was missed at once;
|
||||
// one echo alongside checks the server for the connection dot.
|
||||
if (!handle) void start();
|
||||
else handle.reconnect();
|
||||
refreshAfterResume();
|
||||
void liveness.check();
|
||||
});
|
||||
|
||||
// Reconnect when the network comes back while foregrounded.
|
||||
const unsubscribeNetwork = useNetworkStore.subscribe((state, prev) => {
|
||||
if (state.online && !prev.online && appActive) {
|
||||
if (handle) handle.reconnect();
|
||||
else void start();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
cleanup?.();
|
||||
subscription.remove();
|
||||
unsubscribeNetwork();
|
||||
liveness.stop();
|
||||
handle?.close();
|
||||
handle = null;
|
||||
};
|
||||
}, [client, isAuthenticated]);
|
||||
}, [client, isAuthenticated, haveLiveSession, activeAccountId]);
|
||||
|
||||
// Skip the "Restoring session" flash for returning users: if we already
|
||||
// have a persisted active account, render the main UI immediately with
|
||||
@@ -304,8 +802,8 @@ export default function App() {
|
||||
if (!hasRestoredSession && !hasPersistedAccount) {
|
||||
return (
|
||||
<>
|
||||
<StatusBar style="light" />
|
||||
<LoadingScreen message="Loading..." />
|
||||
<StatusBar style={statusBarStyle} />
|
||||
<LoadingScreen message={t('common.loading', 'Loading...')} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -313,25 +811,29 @@ export default function App() {
|
||||
if (hasRestoredSession && !isAuthenticated) {
|
||||
return (
|
||||
<>
|
||||
<StatusBar style="light" />
|
||||
<StatusBar style={statusBarStyle} />
|
||||
<LoginScreen />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigationContainer ref={navigationRef}>
|
||||
<StatusBar style="light" />
|
||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||
<NavigationContainer ref={navigationRef} theme={navigationTheme}>
|
||||
<StatusBar style={statusBarStyle} />
|
||||
{/* On Fabric, native-stack keeps the screen right below the top live and
|
||||
freezes the ones further down. */}
|
||||
<Stack.Navigator screenOptions={{ headerShown: false, freezeOnBlur: true }} screenLayout={withHosts}>
|
||||
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
|
||||
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
|
||||
<Stack.Screen name="EmailSource" component={EmailSourceScreen} />
|
||||
<Stack.Screen
|
||||
name="Compose"
|
||||
component={ComposeScreen}
|
||||
options={{
|
||||
presentation: 'modal',
|
||||
animation: 'slide_from_bottom',
|
||||
}}
|
||||
// A tablet shows the composer as a panel over the screen it came
|
||||
// from, so that screen stays drawn behind the dimmed backdrop.
|
||||
options={isSplit
|
||||
? { presentation: 'transparentModal', animation: 'fade' }
|
||||
: { presentation: 'modal', animation: 'slide_from_bottom' }}
|
||||
/>
|
||||
<Stack.Screen name="ContactDetail" component={ContactDetailScreen} />
|
||||
<Stack.Screen
|
||||
@@ -343,6 +845,8 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="GroupDetail" component={GroupDetailScreen} />
|
||||
<Stack.Screen name="Scheduled" component={ScheduledScreen} />
|
||||
<Stack.Screen name="UnifiedInbox" component={UnifiedInboxScreen} />
|
||||
<Stack.Screen
|
||||
name="AddAccount"
|
||||
options={{
|
||||
@@ -368,12 +872,10 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.background,
|
||||
gap: spacing.md,
|
||||
},
|
||||
loadingText: {
|
||||
...typography.body,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
# React Native Calendar - Parity Plan
|
||||
|
||||
Bring `repos/react-native/` calendar to parity with the webmail calendar at
|
||||
`components/calendar/`. Webmail totals ~5,940 lines across 19 components plus
|
||||
utility libs; mobile port targets ~2,500 lines with feature parity on the
|
||||
essentials and explicit deferrals for things that don't fit a phone UX.
|
||||
|
||||
## Current state
|
||||
|
||||
- `src/screens/CalendarScreenNew.tsx` - month grid + agenda-for-selected-day,
|
||||
all rendering `MOCK_EVENTS`. Doesn't touch the store.
|
||||
- `src/stores/calendar-store.ts` - CRUD wiring exists (`fetchCalendars`,
|
||||
`fetchEvents`, `createEvent`, `updateEvent`, `deleteEvent`) but no
|
||||
`handleStateChange`, no per-calendar visibility, no range tracking.
|
||||
- `src/api/calendar.ts` - JMAP methods wired (`Calendar/get`,
|
||||
`CalendarEvent/query`, `CalendarEvent/get`, `CalendarEvent/set`).
|
||||
- `src/api/types.ts` - `CalendarEvent` and `Calendar` types match webmail.
|
||||
- No event modal, no detail view, no week/day view, no sidebar, no recurrence.
|
||||
|
||||
## In scope
|
||||
|
||||
### Phase 1 - Data layer (foundation for everything else)
|
||||
|
||||
**`src/stores/calendar-store.ts`** - expand to match webmail's store semantics:
|
||||
|
||||
- `hiddenCalendarIds: Set<string>` persisted via AsyncStorage (matches webmail's
|
||||
`CalendarSidebar` toggle state).
|
||||
- `loadedRange: { after: string; before: string } | null` - track what's fetched
|
||||
so view changes don't re-fetch unnecessarily.
|
||||
- `ensureRange(after, before)` - fetch only if the requested window isn't
|
||||
covered by `loadedRange`; expand the loaded range in place instead of
|
||||
replacing events wholesale, so switching Month → Week doesn't drop data.
|
||||
- `handleStateChange(change)` - subscribe to `Calendar` and `CalendarEvent`
|
||||
typeState keys; on push, refetch calendars and re-query the current window.
|
||||
Already referenced from `App.tsx` push handler - this finally implements it.
|
||||
- `toggleCalendarVisibility(id)` / `setCalendarHidden(id, hidden)`.
|
||||
- Selectors: `visibleCalendars`, `visibleEvents` (filtered by hidden set).
|
||||
|
||||
**`src/lib/calendar-utils.ts`** (new) - port from webmail's `lib/calendar-utils.ts`:
|
||||
|
||||
- `parseLocalDateTime(iso)` - JSCalendar start strings are floating local time
|
||||
(`2026-04-20T09:00:00`, no Z). Parse without timezone shifts.
|
||||
- `parseDuration(iso8601)` - `PT1H30M` → milliseconds.
|
||||
- `eventTimeRange(event)` - returns `{ start: Date, end: Date, allDay: boolean }`
|
||||
handling `showWithoutTime`.
|
||||
- `eventsOnDay(events, day)` - day overlap check (not just start-matches-day).
|
||||
- `getCalendarColor(cal)` - fallback to a deterministic palette when `color`
|
||||
is missing, matching `theme/tokens.ts` `colors.calendar.*`.
|
||||
|
||||
**`src/lib/recurrence-expansion.ts`** (new) - minimal port of webmail's RRULE
|
||||
expander. Webmail's version supports `byDay`/`byMonth`/`byMonthDay`/`count`/`until`.
|
||||
Mobile port: same surface, same tests. This is the hard part - allocate ~400
|
||||
lines. Drives correct rendering of recurring events across the visible window.
|
||||
|
||||
### Phase 2 - Views
|
||||
|
||||
**`src/screens/CalendarScreenNew.tsx`** - replace mocks, gain view switching:
|
||||
|
||||
- On mount: `fetchCalendars()`, then `ensureRange` for visible window.
|
||||
- View mode toggle → Month / Week / Agenda (Day view folded into Week for phone).
|
||||
- Header shows month/week label; prev/next shifts by the current view's step.
|
||||
- "Today" button resets both `currentDate` and `selectedDate`.
|
||||
- FAB opens create modal with `start = selectedDate at next half hour`.
|
||||
|
||||
**`src/components/calendar/MonthView.tsx`** (extract from current screen,
|
||||
~180 lines) - real events, up to 3 colored dots per day, "+N" indicator when
|
||||
more. Tap day → selects + scrolls agenda below. Long-press day → opens create
|
||||
modal with that date.
|
||||
|
||||
**`src/components/calendar/WeekView.tsx`** (new, ~350 lines) - port of
|
||||
`calendar-week-view.tsx` (511 lines) trimmed for mobile:
|
||||
|
||||
- 7 day columns, hour rows from 6am–10pm scrollable to 0–24.
|
||||
- All-day strip at top.
|
||||
- Events positioned absolutely by `utcStart`/`utcEnd`, width shared among
|
||||
overlapping events (port webmail's stacking algorithm).
|
||||
- Tap event → detail modal. Long-press empty slot → create modal pre-filled.
|
||||
- Horizontal swipe → prev/next week (react-native-gesture-handler already
|
||||
available via react-navigation).
|
||||
- _Deferred_: drag-to-reschedule, drag-to-resize. Phone targets are too small
|
||||
for it to be better than tap-edit.
|
||||
|
||||
**`src/components/calendar/AgendaView.tsx`** (new, ~120 lines) - port of
|
||||
`calendar-agenda-view.tsx`. Flat list of upcoming events grouped by day, 30
|
||||
days forward. Taps open detail modal.
|
||||
|
||||
### Phase 3 - Event interaction
|
||||
|
||||
**`src/components/calendar/EventDetailSheet.tsx`** (new, ~280 lines) - bottom
|
||||
sheet presenting a single event, port of `event-detail-popover.tsx` (631 lines
|
||||
on web - phone version drops the hover card chrome and the inline-edit fields):
|
||||
|
||||
- Header: calendar color bar, title, date/time range (or "All day"), calendar name.
|
||||
- Sections: location, description (linkified), participants with RSVP status,
|
||||
recurrence summary (e.g. "Weekly on Mon, Wed - until 2026-06-01"), reminders.
|
||||
- Actions row: Edit, Delete, Duplicate, "Open in email" if created from invite.
|
||||
- If recurring: delete/edit prompts via `RecurrenceScopeDialog` ("This event
|
||||
only" / "This and following" / "All events").
|
||||
|
||||
**`src/components/calendar/EventModal.tsx`** (new, ~550 lines) - full-screen
|
||||
modal for create + edit, port of `event-modal.tsx` (1,072 lines on web, trimmed):
|
||||
|
||||
- Title, calendar picker (color swatches), all-day toggle.
|
||||
- Start/end date+time pickers (`@react-native-community/datetimepicker` - add dep).
|
||||
- Location, description (multiline).
|
||||
- Participant input with autocomplete from contacts store (port of
|
||||
`participant-input.tsx`, ~180 lines).
|
||||
- Repeat picker: None / Daily / Weekly (weekday chips) / Monthly / Yearly,
|
||||
plus end: Never / After N / On date. Advanced iCal rules (BYSETPOS etc.)
|
||||
are deferred - covers the 95% case.
|
||||
- Alerts: None / At start / 5m / 15m / 30m / 1h / 1d before, mapping to
|
||||
`Alert.trigger.offset`.
|
||||
- Timezone: default to device TZ; optional override picker hidden behind
|
||||
"Advanced" disclosure.
|
||||
- Save path handles "this instance only" override vs master update for
|
||||
recurring events (write to `recurrenceOverrides[recurrenceId]`).
|
||||
|
||||
**`src/components/calendar/RecurrenceScopeDialog.tsx`** (new, ~90 lines) -
|
||||
direct port of `recurrence-scope-dialog.tsx`. Three-button picker before
|
||||
edit/delete of a recurring instance.
|
||||
|
||||
### Phase 4 - Multi-calendar
|
||||
|
||||
**`src/components/calendar/CalendarSidebarDrawer.tsx`** (new, ~200 lines) -
|
||||
port of `calendar-sidebar-panel.tsx` (273 lines). Slides in from the left
|
||||
(reuses `SidebarDrawer.tsx`):
|
||||
|
||||
- List of calendars with color swatch + visibility toggle + name.
|
||||
- "My calendars" vs "Subscribed" sections if `myRights` differs.
|
||||
- Tap calendar → highlight; long-press → rename/color/hide (optional, phase 4.5).
|
||||
- Footer: "Add calendar" (deferred to Phase 6).
|
||||
|
||||
**`src/theme/tokens.ts`** - audit `colors.calendar.*` palette against webmail's
|
||||
calendar color constants (`lib/calendar-utils.ts` `CALENDAR_COLOR_PALETTE`);
|
||||
add any missing shades.
|
||||
|
||||
### Phase 5 - Polish
|
||||
|
||||
- Pull-to-refresh on each view → forces re-fetch of current window.
|
||||
- Empty states per view (no calendars yet / no events this month).
|
||||
- Loading skeleton for month grid and week grid.
|
||||
- Error banner when fetch fails (pattern from `EmailListScreen`).
|
||||
- Respect `email-store` push via `handleStateChange` so events update live
|
||||
when another client creates one.
|
||||
|
||||
## Explicitly deferred (out of scope)
|
||||
|
||||
These are in the webmail but don't make sense for a first mobile pass:
|
||||
|
||||
- **iCal import/subscribe modals** (`ical-import-modal.tsx` 455 lines,
|
||||
`ical-subscription-modal.tsx` 220 lines) - file picker + URL subscription
|
||||
with auth flows. Better as a dedicated settings flow later.
|
||||
- **Tasks module** (`task-list-view.tsx`, `task-modal.tsx`, `task-toolbar.tsx`) -
|
||||
JSCalendar `@type: 'Task'` is a separate surface from events; worth its own
|
||||
screen, not bolted onto calendar.
|
||||
- **Drag to reschedule / resize** - mobile tap targets are too small for the
|
||||
fine-grained control the web drag gives; tap-edit is the mobile idiom.
|
||||
- **Quick event input** (`quick-event-input.tsx`) - NLP parser ("Lunch tomorrow
|
||||
at noon"). Neat on desktop, marginal on phone where typing is already slow.
|
||||
- **Event context menu** (`event-context-menu.tsx`) - right-click is desktop-
|
||||
only. Actions move into the detail sheet's action row.
|
||||
- **Mini-calendar** (`mini-calendar.tsx`) - web uses it as a sidebar date
|
||||
picker. Mobile already has the main month view; redundant.
|
||||
- **Birthday calendar auto-derived events** - lower priority; leave the
|
||||
contacts-store integration for a follow-up.
|
||||
|
||||
## Dependencies to add
|
||||
|
||||
- `@react-native-community/datetimepicker` - native date/time pickers.
|
||||
- `react-native-reanimated` is already in (via react-navigation); reuse for
|
||||
bottom-sheet animations on `EventDetailSheet`.
|
||||
- No new calendar library - reimplement RRULE expansion in ~400 lines rather
|
||||
than pulling in `rrule.js` (which adds ~180KB and expects browser APIs).
|
||||
|
||||
## File map (new / modified)
|
||||
|
||||
```
|
||||
repos/react-native/src/
|
||||
├── screens/
|
||||
│ └── CalendarScreenNew.tsx [rewrite]
|
||||
├── components/
|
||||
│ └── calendar/ [new directory]
|
||||
│ ├── MonthView.tsx [new]
|
||||
│ ├── WeekView.tsx [new]
|
||||
│ ├── AgendaView.tsx [new]
|
||||
│ ├── EventCard.tsx [new]
|
||||
│ ├── EventDetailSheet.tsx [new]
|
||||
│ ├── EventModal.tsx [new]
|
||||
│ ├── ParticipantInput.tsx [new]
|
||||
│ ├── RecurrenceScopeDialog.tsx [new]
|
||||
│ ├── CalendarSidebarDrawer.tsx [new]
|
||||
│ └── index.ts [new]
|
||||
├── stores/
|
||||
│ └── calendar-store.ts [extend]
|
||||
├── lib/
|
||||
│ ├── calendar-utils.ts [new]
|
||||
│ └── recurrence-expansion.ts [new]
|
||||
└── theme/
|
||||
└── tokens.ts [audit calendar palette]
|
||||
```
|
||||
|
||||
## Testing approach
|
||||
|
||||
- `vitest.config.ts` already exists. Unit tests for:
|
||||
- `recurrence-expansion` (daily/weekly/monthly, count+until, byDay).
|
||||
- `calendar-utils` (duration parse, all-day overlap, DST boundary).
|
||||
- Store: `ensureRange` dedup, `handleStateChange` refetch trigger.
|
||||
- Manual emulator test matrix:
|
||||
- Create event (timed, all-day, recurring), verify server round-trip.
|
||||
- Edit recurring event via scope dialog.
|
||||
- Delete recurring instance vs series.
|
||||
- Toggle calendar visibility, confirm events disappear.
|
||||
- Push update from webmail, confirm mobile refreshes.
|
||||
|
||||
## Build order (suggested)
|
||||
|
||||
1. Phase 1 in full - nothing else works without real data.
|
||||
2. MonthView + EventDetailSheet (read path) - first thing user sees.
|
||||
3. EventModal create path - unblocks manual testing of write path.
|
||||
4. AgendaView + CalendarSidebarDrawer - pulls the rest of month together.
|
||||
5. WeekView - biggest UI, easiest to defer if time runs short.
|
||||
6. EventModal edit + recurrence scope - finishes the edit surface.
|
||||
7. Phase 5 polish.
|
||||
|
||||
Rough size estimate: ~2,500 lines of new RN code + ~300 lines of store/util
|
||||
changes. Each phase is ship-safe on its own - the app stays functional even if
|
||||
we stop at Phase 2.
|
||||
@@ -1,3 +1,64 @@
|
||||
Bulwark Mobile
|
||||
Copyright (C) 2026 Bulwark Project Authors
|
||||
|
||||
This project is licensed under the GNU Affero General Public License,
|
||||
version 3 only, with the additional permission for app store
|
||||
distribution below. That permission is provisional; see the
|
||||
reservation at its end.
|
||||
|
||||
Additional permission under GNU AGPL version 3 section 7
|
||||
|
||||
The copyright holders of this Program give you additional permission to
|
||||
convey a covered work (this Program, a modified version of it, or a work
|
||||
that contains parts of it) through an app store, even though the app
|
||||
store's terms of service, usage rules or technical measures place
|
||||
restrictions on recipients that section 10 of this License would otherwise
|
||||
not allow, such as a limit on the number of devices a copy may be installed
|
||||
on, or digital rights management applied to the copy.
|
||||
|
||||
This permission applies only if you:
|
||||
|
||||
a) make the Corresponding Source of each version you convey through the
|
||||
app store available, at no charge and under the terms of this
|
||||
License, from a network server that anyone can access;
|
||||
|
||||
b) state, in the app store listing or inside the application, where
|
||||
that Corresponding Source can be obtained; and
|
||||
|
||||
c) do not impose any further restrictions of your own on the exercise
|
||||
of the rights granted under this License.
|
||||
|
||||
"App store" means a digital distribution service for applications that is
|
||||
operated by a third party, such as the Apple App Store and TestFlight,
|
||||
Google Play, the Microsoft Store, the Samsung Galaxy Store or the Huawei
|
||||
AppGallery.
|
||||
|
||||
This permission covers only the parts of a covered work that its copyright
|
||||
holders license under this License; it does not extend to parts licensed
|
||||
under other terms. As section 7 of this License allows, you may remove this
|
||||
permission from copies you convey and from your modified versions.
|
||||
|
||||
Reservation
|
||||
|
||||
This additional permission has not been finalized. The contributors of
|
||||
earlier parts of this Program have been asked whether they agree to it:
|
||||
https://github.com/orgs/bulwarkmail/discussions/1113
|
||||
|
||||
Until that request is settled, the permission is granted subject to this
|
||||
reservation:
|
||||
|
||||
- It applies to all contributions made on or after 30 September 2026.
|
||||
These contributions are accepted under this License together with
|
||||
this permission.
|
||||
|
||||
- It applies to parts of this Program written before that date only
|
||||
where their copyright holders have agreed to it. Parts whose
|
||||
copyright holders do not agree will be rewritten or removed.
|
||||
|
||||
This reservation will be removed once every earlier contributor has
|
||||
agreed or their parts have been replaced.
|
||||
|
||||
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
# Webmail parity checklist
|
||||
|
||||
Audit of **Bulwark Mobile** (`bulwarkmail/native`, v0.1.58 @ `82b97fa`) against
|
||||
**Bulwark Webmail** (`bulwarkmail/webmail`, v1.9.2 @ `39dcc6b2`), taken on
|
||||
2026-08-29. The goal is feature *and* bugfix parity: every user-facing webmail
|
||||
behaviour, and every webmail changelog fix since the native app was started
|
||||
(1.4.5 → 1.9.2), was checked against the native code.
|
||||
|
||||
The audit was done by reading code on both sides, not by running the app. Every
|
||||
finding cites `file:line` on both sides so it can be re-verified quickly; a
|
||||
sample of the 32 P1 findings was additionally spot-checked by hand.
|
||||
|
||||
## How to use this document
|
||||
|
||||
- The detailed findings live in [docs/parity/](docs/parity/), one file per area.
|
||||
Tick the `- [ ]` boxes there as you fix things; this file is the index.
|
||||
- Each finding has a **priority** and a **category**:
|
||||
|
||||
| Priority | Meaning |
|
||||
|---|---|
|
||||
| `P1` | data loss, wrong send/delete, security, crash, or a core flow that is blocked |
|
||||
| `P2` | notable feature missing or visibly wrong |
|
||||
| `P3` | polish, nice-to-have, minor divergence |
|
||||
|
||||
| Category | Meaning |
|
||||
|---|---|
|
||||
| `missing` | webmail has it, native does not |
|
||||
| `partial` | native has a reduced version |
|
||||
| `bug` | native has it, but it is wrong |
|
||||
| `bugfix-parity` | a bug the webmail fixed (changelog / issue number cited) that is still live in native |
|
||||
| `rn-only-bug` | a bug that only exists in native (found while reading) |
|
||||
|
||||
- `WEB` paths are relative to the webmail repo, `RN` paths to this repo.
|
||||
Issue numbers `#NNN` refer to the **webmail** tracker unless prefixed
|
||||
"native #NN".
|
||||
- Each area file ends with **Verified at parity** (do not redo) and
|
||||
**N/A on mobile** (deliberately out of scope) lists.
|
||||
- A few findings appear in two areas because they sit on a boundary (e.g.
|
||||
"forward drops attachments" is in both viewer and composer). Fix once, tick
|
||||
both.
|
||||
|
||||
## Baseline health of this repo (2026-08-29)
|
||||
|
||||
- [x] **`npm test` is red on `main`** — `P2` — `rn-only-bug` *(fixed in b84d4d8)*
|
||||
- `src/stores/__tests__/auth-store.test.ts` fails to load with
|
||||
`SyntaxError: Unexpected token 'typeof'`. The chain
|
||||
`auth-store → email-store → outbox-store → network-store` imports
|
||||
`@react-native-community/netinfo`, which `src/test-setup.ts` never mocks
|
||||
(it mocks `react-native`, `expo-secure-store`, `expo-web-browser`,
|
||||
AsyncStorage only). Broken since `5d47f47` (2026-05-30) when the outbox
|
||||
import was added; `email-store.test.ts` only passes because it mocks
|
||||
`../outbox-store` itself.
|
||||
- Fix hint: add `vi.mock('@react-native-community/netinfo', () => ({ default: { addEventListener: vi.fn(() => () => {}), fetch: vi.fn(async () => ({ isConnected: true, isInternetReachable: true })) } }))`
|
||||
to `src/test-setup.ts`. Expected afterwards: 31/31 suites (432+ tests).
|
||||
- [x] **CI runs neither `tsc` nor `vitest`** — `P2` — `missing` *(fixed in fb4796e)*
|
||||
- `.github/workflows/` only has `release-android.yml` and `release-ios.yml`.
|
||||
Add a `ci.yml` running `npm ci && npm run typecheck && npm test` on push/PR.
|
||||
- `npx tsc --noEmit` is clean.
|
||||
- Uncommitted work in the tree at audit time: ascending/descending mail sort
|
||||
(native #5) in `src/stores/email-store.ts`, `src/stores/settings-store.ts`,
|
||||
`src/screens/EmailListScreen.tsx`, `src/stores/__tests__/email-store.test.ts`.
|
||||
Findings that touch list ordering assume that lands.
|
||||
- [x] **README is stale** — `P3` — it says "Filters & rules, S/MIME, plugins, *(fixed in 9ae91a6)*
|
||||
themes, file storage – UI stubs only". Filters, Files and file sharing are
|
||||
real (see areas 07); S/MIME, plugins, themes and sidebar apps are still
|
||||
stubs (area 08).
|
||||
|
||||
## Status after the 2026-08-29 fix pass
|
||||
|
||||
Worked down the same day, area by area, by one orchestrating session plus one
|
||||
agent per area, each change committed on its own (`type: short description`).
|
||||
Per-area counts of ticked items are in the area files; every tick carries the
|
||||
commit hash. The remaining `- [ ]` items are either deferred with a note
|
||||
("deferred: …") or need work outside this repo (webmail hand-off `flow=oauth`
|
||||
for TOTP accounts, relay APNs/UnifiedPush transports, a server-side settings
|
||||
store for native #1).
|
||||
|
||||
## Status after the 2026-09-24 fix pass
|
||||
|
||||
The native audit of 2026-09-22 ([docs/audit-2026-09.md](docs/audit-2026-09.md))
|
||||
spot-checked this list and found 14 ticks that were broken or overstated and 11
|
||||
open items that were already done. The 2026-09-24 fix pass fixed 13 of the 14
|
||||
(their ticks now also name the fixing commit; the date-locale item is open
|
||||
again), dropped three obsolete items and ticked what it completed: 380 of 415
|
||||
items are done, 35 open. What still needs a device check or a decision is in
|
||||
the audit's "Fix pass, 2026-09-24" section.
|
||||
|
||||
## Areas
|
||||
|
||||
| # | Area | File | Items | Done | Open | P1 | P2 | P3 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 01 | Authentication, login, session, multi-account | [docs/parity/01-auth-accounts.md](docs/parity/01-auth-accounts.md) | 30 | 29 | 1 | 4 | 8 | 18 |
|
||||
| 02 | Mail list, folders, unified views, search, tags | [docs/parity/02-mail-list-folders.md](docs/parity/02-mail-list-folders.md) | 54 | 50 | 4 | 1 | 20 | 33 |
|
||||
| 03 | Email viewer, thread view, rendering, attachments | [docs/parity/03-email-viewer.md](docs/parity/03-email-viewer.md) | 46 | 45 | 1 | 6 | 13 | 26 |
|
||||
| 04 | Composer, drafts, sending, identities, templates, scheduled send | [docs/parity/04-composer-send.md](docs/parity/04-composer-send.md) | 51 | 46 | 5 | 5 | 14 | 32 |
|
||||
| 05 | Calendar and tasks | [docs/parity/05-calendar.md](docs/parity/05-calendar.md) | 50 | 43 | 7 | 5 | 20 | 25 |
|
||||
| 06 | Contacts and address books | [docs/parity/06-contacts.md](docs/parity/06-contacts.md) | 50 | 46 | 4 | 1 | 18 | 31 |
|
||||
| 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 |
|
||||
| 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** |
|
||||
|
||||
Counts are of the `- [ ]` and `- [x]` items per file as of 2026-09-24. Done and
|
||||
Open split them by tick; the P columns count the priority tags on those items
|
||||
(one item carries none).
|
||||
|
||||
## All P1 findings (fix these first)
|
||||
|
||||
### Data loss / wrong send
|
||||
- [x] No draft autosave, no save-on-close, and the OS back gesture bypasses the discard guard — closing a compose loses everything. → [04](docs/parity/04-composer-send.md) *(fixed in 5b72c4d)*
|
||||
- [x] Cannot open or edit an existing draft (Drafts folder opens the read-only viewer). → [04](docs/parity/04-composer-send.md) *(fixed in 5b72c4d)*
|
||||
- [x] Message is filed into **Sent before `EmailSubmission/set`**; a failed send leaves a never-sent copy in Sent and a retry duplicates it. → [04](docs/parity/04-composer-send.md) *(fixed in ccbe67c)*
|
||||
- [x] `In-Reply-To` / `References` are written with the **JMAP email id** instead of the RFC Message-ID (`messageId`/`references` are never even fetched) — every reply breaks threading in recipients' clients. → [03](docs/parity/03-email-viewer.md), [04](docs/parity/04-composer-send.md) *(fixed in 7f956d6)*
|
||||
- [x] Reply addressing: `Reply-To` ignored, the user's own address kept on reply-all, wrong recipients on self-sent replies (#703, 1.9.0). → [03](docs/parity/03-email-viewer.md) *(fixed in 7f956d6)*
|
||||
- [x] **Forward drops every attachment.** → [03](docs/parity/03-email-viewer.md), [04](docs/parity/04-composer-send.md) *(fixed in 7f956d6)*
|
||||
- [x] Sieve: RN generator has no `attachment` field and emits `header :contains "undefined" ""` for webmail-authored attachment rules — **any save on the phone corrupts the shared server script**. → [07](docs/parity/07-filters-vacation-files.md) *(fixed in 1c6aa68)*
|
||||
- [x] Sieve: multi-value conditions (`string[]`, webmail 1.7.3) make the whole script "opaque" on RN; "Reset to visual builder" then wipes all rules. → [07](docs/parity/07-filters-vacation-files.md) *(fixed in 1c6aa68)*
|
||||
- [x] `FilterRuleModal` is always mounted and never re-seeds: editing rule B shows and **saves rule A's fields under B's id**. → [07](docs/parity/07-filters-vacation-files.md) *(fixed in 1c6aa68)*
|
||||
- [x] Calendar recurrence scope dialog is a stub: "This event" / "This and following" act as "All events" — deleting one occurrence **destroys the series**. → [05](docs/parity/05-calendar.md) *(fixed in 161baf1)*
|
||||
- [x] Editing an expanded occurrence rewrites the master's `start` to the occurrence date (series shifts, earlier occurrences vanish). → [05](docs/parity/05-calendar.md) *(fixed in 161baf1)*
|
||||
- [x] All-day events grow by one day on every create-via-toggle and every re-save. → [05](docs/parity/05-calendar.md) *(fixed in 58a6f45)*
|
||||
- [x] Events are created without `timeZone` (floating time). → [05](docs/parity/05-calendar.md) *(fixed in 58a6f45)*
|
||||
- [x] Invitations are never sent: participants use the retired `sendTo`, no organizer participant, no `organizerCalendarAddress` (#500, #731). → [05](docs/parity/05-calendar.md) *(fixed in 5dc4070)*
|
||||
- [x] vCard import writes dates as raw strings instead of RFC 9553 PartialDate (#224) — Stalwart rejects the whole card, so any contact with a birthday is lost on import. → [06](docs/parity/06-contacts.md) *(fixed in e650cb2)*
|
||||
|
||||
### Core flow blocked / wrong content shown
|
||||
- [x] TOTP / 2FA password login is impossible (402 is shown as a generic failure). → [01](docs/parity/01-auth-accounts.md) *(fixed in b52768e)*
|
||||
- [x] Webmail password handoff breaks for TOTP-protected accounts (cross-repo: webmail should hand off `flow=oauth` after a TOTP-upgraded login). → [01](docs/parity/01-auth-accounts.md) *(fixed in 0d94d76)*
|
||||
- [x] A failed "add account" resets the singleton client before connecting, killing the live session until relaunch. → [01](docs/parity/01-auth-accounts.md) *(fixed in b52768e)*
|
||||
- [x] Account Security screen is dead against every Stalwart server: it checks `session.capabilities` but Stalwart advertises `urn:stalwart:jmap` only in `accountCapabilities` (verified on 0.16.19) — **native #47**. → [01](docs/parity/01-auth-accounts.md) *(fixed in 032732c)*
|
||||
- [x] Thread view: only the newest message of a thread is reachable; older messages cannot be opened unless threading is disabled. → [03](docs/parity/03-email-viewer.md) *(fixed in d2ed27f)*
|
||||
- [x] Pager shows another message's body (or nothing) when the opened id is not in the active folder page — unified inbox, group inboxes, contact activity. → [02](docs/parity/02-mail-list-folders.md), [03](docs/parity/03-email-viewer.md) *(fixed in c8be383)*
|
||||
- [x] HTML-only messages are rendered — and quoted in replies — as raw HTML source (**native #46**). `<style>` stripping and an old dark-mode CSS revision are the likely cause of **native #49**. → [03](docs/parity/03-email-viewer.md) *(fixed in 06742ef)*
|
||||
|
||||
### JMAP client, live sync, security
|
||||
- [x] No request deadline on any fetch — a stalled socket (iOS background suspension, dead pooled connection) hangs send/save forever (#702). → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in 0b1c240)*
|
||||
- [x] Most `src/api/*.ts` functions index `methodResponses[0][1]` blindly and never check `error` / `notUpdated` / `notDestroyed` — a rejected mutation is reported as success and the outbox drops it. → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in ccbe67c)*
|
||||
- [x] Password change leaves the stored credential stale; the next launch evicts the account. → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in 032732c)*
|
||||
- [x] No `AppState` handling: SSE is never paused in background nor recovered on foreground, and `react-native-sse` never reconnects after a status-0 network error — push is silently dead after the first blip. → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in edc26ce)*
|
||||
- [x] SSE `Authorization` header is captured once; after an OAuth refresh every reconnect sends the stale token (401 → re-poll every 5 s forever). → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in edc26ce)*
|
||||
- [x] Push effect is keyed on the singleton client, so the SSE stream stays bound to the previous account after `switchAccount`. → [09](docs/parity/09-jmap-core-sync-security.md) *(fixed in edc26ce)*
|
||||
- [x] Webmail password handoff sends the clear-text password in a custom-scheme redirect fragment that any app can register; OAuth `state` uses `Math.random`; `server_url`/`token_endpoint` in the callback are trusted as-is. → [09](docs/parity/09-jmap-core-sync-security.md), [01](docs/parity/01-auth-accounts.md) *(fixed in 2c0dbd1)*
|
||||
|
||||
### Repo health
|
||||
- [x] `npm test` is red on `main` (see Baseline health above). *(fixed in b84d4d8)*
|
||||
|
||||
## Native issues mapped to findings
|
||||
|
||||
| Native issue | Where it is explained |
|
||||
|---|---|
|
||||
| #49 Unreadable text in dark-mode emails | [03](docs/parity/03-email-viewer.md) `<style>` stripping + stale dark-mode CSS |
|
||||
| #48 / #44 UnifiedPush / non-FCM push | [08](docs/parity/08-settings-push-i18n-ui.md) push section |
|
||||
| #47 Account security not accessible | [01](docs/parity/01-auth-accounts.md) capability map bug (P1) |
|
||||
| #46 email preview shows css | [03](docs/parity/03-email-viewer.md) HTML-only body detection (P1) |
|
||||
| #45 error when re-registering to relay | [08](docs/parity/08-settings-push-i18n-ui.md) push section |
|
||||
| #34 Native contacts/calendar sync adapters | done on Android: Settings → Contacts / Calendar → "Sync to this device" syncs both ways with the device's Contacts and Calendar apps ([design](docs/device-sync.md)); iOS has no API for it |
|
||||
| #5 Sort ascending/descending | in progress in the working tree; ordering presets in [02](docs/parity/02-mail-list-folders.md) |
|
||||
| #3 TLS client auth | Android-only today; gaps in [01](docs/parity/01-auth-accounts.md) and [09](docs/parity/09-jmap-core-sync-security.md) |
|
||||
| #1 Shared settings between webmail and native | [08](docs/parity/08-settings-push-i18n-ui.md) — webmail sync is server-side/cookie-bound; a JMAP-blob design is sketched there |
|
||||
|
||||
## Cross-cutting themes
|
||||
|
||||
These come up in nearly every area; fixing them centrally pays off more than
|
||||
per-screen patches.
|
||||
|
||||
1. **Email headers are never fetched.** `EMAIL_FULL_PROPERTIES` lacks
|
||||
`messageId`, `inReplyTo`, `references`, `headers`, `replyTo` usage. This
|
||||
single gap causes the broken threading headers, missing Reply-To handling,
|
||||
no SPF/DKIM/DMARC chips, no List-Unsubscribe, no read receipts, no details
|
||||
panel. (03, 04)
|
||||
2. **No thread model.** The list collapses threads but the viewer is a
|
||||
single-message pager over the folder page. (02, 03)
|
||||
3. **Hard-coded English.** RN has an i18n layer and 15 vendored locales, but
|
||||
84 of 94 screen/component files never call `t()`; calendar, files,
|
||||
vacation, scheduled, login and every settings pane except Language and
|
||||
Filters are English-only. Catalogs are ~6 weeks behind webmail (640 missing
|
||||
keys), and there is no interpolation, plurals or RTL. (all areas, 08)
|
||||
4. **Single-account assumptions.** Contacts, calendars (subscriptions), filters,
|
||||
vacation and Files are primary-account only; shared/group accounts work
|
||||
for mail folders but not elsewhere. (01, 05, 06, 07)
|
||||
5. **Webmail bugfixes that are one-line ports:** `$junk`/`$notjunk` flip on
|
||||
spam (#850), search defaults to all folders (#788), folder substring dedup
|
||||
(#771), permanent-delete confirmation, `$pinned` vs `$important`, FileNode
|
||||
`modified` vs `updated` (#700), percent-decoding WebDAV names (#869),
|
||||
RSCALE/SKIP=OMIT removal (#805), first-touch gate for lazy default
|
||||
calendar/address book creation (#907), `supported-calendar-component-set`
|
||||
pinning (#760), request timeouts (#702), relative session URL resolution.
|
||||
6. **Settings that do nothing.** 16–17 persisted keys are never read outside
|
||||
the settings UI (layout, sound, calendar notifications, sidebar apps,
|
||||
S/MIME, plugins, theme id, files thumbnails, …). Either wire them or remove
|
||||
the toggles. (08, table in that file)
|
||||
7. **Deep links / intents.** `bulwarkmobile://` is registered but nothing
|
||||
handles it; no `mailto:` or share-to-app intent filters. (04, 08)
|
||||
8. **Push.** Subscribes to `Email`+`Mailbox` instead of `EmailDelivery`, no
|
||||
`emailPush` spam filter, ignores relay-forwarded ids and guesses "newest
|
||||
unread", never matches the payload to an account, no device list/revoke,
|
||||
Android/FCM only. (08)
|
||||
9. **JMAP client robustness.** `src/api/jmap-client.ts` is a thin transport
|
||||
with none of the layers webmail added since 1.4.5: request deadlines,
|
||||
`maxConcurrentRequests` back-off, transient retry, rate-limit pause,
|
||||
`maxObjectsInSet` / `maxSizeUpload` chunking, keep-alive monitoring,
|
||||
first-touch serialisation, and — most importantly — method-error checking.
|
||||
A shared `requireMethodResult` / `assertSetResult` helper used by every
|
||||
`src/api/*.ts` function fixes a whole class of silent failures. (09)
|
||||
10. **Live sync lifecycle.** No `AppState` or connectivity hooks around the
|
||||
EventSource; reconnect, token refresh, account switch and client-cert
|
||||
users all leave the stream dead or stale. (09)
|
||||
|
||||
## Suggested order of work
|
||||
|
||||
1. Repo health: fix `test-setup.ts`, add CI. Half a day.
|
||||
2. The P1 list above, roughly in the order given (composer/send correctness,
|
||||
Sieve corruption, calendar write path, vCard dates, auth blockers, viewer,
|
||||
JMAP client + SSE lifecycle).
|
||||
3. Cross-cutting themes 1, 2 and 9 (fetch headers, thread view, method-error
|
||||
checking + request deadline) — they unlock a large slice of the P2 items in
|
||||
03/04/09 and make the outbox trustworthy.
|
||||
4. P2 items per area, starting with 02 (spam keywords, search scope, unified
|
||||
inbox actions/paging, tags in rows) and 08 (push correctness, i18n
|
||||
catalogs + `t()` sweep).
|
||||
5. P3 items opportunistically while touching the same files.
|
||||
|
||||
## Explicitly out of scope on mobile
|
||||
|
||||
Collected from the per-area N/A lists so nobody re-litigates them: keyboard
|
||||
shortcuts, drag & drop, three-pane / Pro split-screen shell, browser
|
||||
back/forward and permalinks in the address bar, PWA install / service worker,
|
||||
print, guided tour, CSP nonces / server-side SSRF guards, admin dashboard and
|
||||
setup wizard, plugin sandbox and marketplace (a minimal "sidebar apps in a
|
||||
WebView" subset is described in 08), impersonation, TOTP re-auth dialog for
|
||||
cookie sessions, server-side telemetry, OpenGraph cards. Where a webmail
|
||||
feature is server-mediated (settings sync, iCal fetch proxy, TOTP token
|
||||
exchange, push relay list) the area file says what the native equivalent
|
||||
would be.
|
||||
|
||||
## Keeping this current
|
||||
|
||||
Re-run the audit after each webmail release: read the new changelog "Fixes"
|
||||
bullets, decide `RN affected / not affected / N/A`, and append to the matching
|
||||
area file. When a finding is fixed, tick it and add the native commit hash in
|
||||
the same line so the next audit can skip it.
|
||||
@@ -1,9 +1,9 @@
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
|
||||
<img src="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main//public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main/public/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main/public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" />
|
||||
<img src="https://raw.githubusercontent.com/bulwarkmail/webmail/refs/heads/main/public/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg" alt="Bulwark Webmail" width="280" />
|
||||
</picture>
|
||||
|
||||
</div>
|
||||
@@ -14,6 +14,11 @@
|
||||
|
||||
React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwarkmail/webmail) - a JMAP-based mail, calendar, and contacts app.
|
||||
|
||||
## Try it
|
||||
|
||||
- **iPhone and iPad:** join the beta on TestFlight: https://testflight.apple.com/join/rumcjNSp. A new release shows up there once Apple has reviewed it, usually within a day.
|
||||
- **Android:** download the APK from the [latest release](https://github.com/bulwarkmail/native/releases/latest).
|
||||
|
||||
## What works today
|
||||
|
||||
- Sign in to any JMAP server (e.g. Stalwart)
|
||||
@@ -21,15 +26,16 @@ React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwa
|
||||
- Email list, threads, compose
|
||||
- Calendar (basic)
|
||||
- Contacts (basic)
|
||||
- Push notifications via FCM relay
|
||||
- Push notifications via the Bulwark relay - FCM by default, or [UnifiedPush](https://unifiedpush.org) (e.g. ntfy) for devices without Google Play services
|
||||
- Android: contacts and calendars sync both ways with the phone's Contacts and Calendar apps (Settings → Contacts / Calendar → Sync to this device)
|
||||
- In-app sideload updates from GitHub Releases
|
||||
|
||||
## What's missing or rough
|
||||
|
||||
- iOS build is untested
|
||||
- Filters & rules, S/MIME, plugins, themes, file storage - UI stubs only
|
||||
- iOS builds, but push notifications, client certificates and syncing with the phone's contacts and calendars are Android-only so far
|
||||
- S/MIME, plugins, themes - UI stubs only (filters & rules, the vacation responder and file storage are real implementations)
|
||||
- Calendar editing is partial; contacts editing is basic
|
||||
- No Play Store distribution yet (sideload APK from Releases)
|
||||
- No Play Store or App Store listing yet: Android is a sideloaded APK, iOS a TestFlight beta (see [Try it](#try-it))
|
||||
|
||||
## Run locally
|
||||
|
||||
@@ -40,8 +46,9 @@ npx expo start
|
||||
|
||||
Then press `a` for Android, `i` for iOS, or scan the QR with Expo Go.
|
||||
|
||||
For a release APK build see [.github/workflows/release-android.yml](.github/workflows/release-android.yml).
|
||||
For release APK builds and signing see [docs/android-release.md](docs/android-release.md).
|
||||
For iOS builds and TestFlight distribution see [docs/ios-release.md](docs/ios-release.md).
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0-only. See [LICENSE](LICENSE).
|
||||
AGPL-3.0-only, with an additional permission to distribute the app through app stores such as the Apple App Store and Google Play. See [LICENSE](LICENSE). Contributions are accepted under the same terms. The permission is provisional until every earlier contributor has agreed to it ([consent request](https://github.com/orgs/bulwarkmail/discussions/1113)); contributions made since 30 September 2026 are already covered.
|
||||
|
||||
@@ -7,6 +7,62 @@ def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
|
||||
|
||||
def appVersionName = file("${projectRoot}/VERSION").text.trim()
|
||||
|
||||
/**
|
||||
* versionCode has to grow with every release, or Android refuses the update as
|
||||
* a downgrade. It is derived from VERSION as
|
||||
*
|
||||
* major * 10000000 + minor * 10000 + patch (0.1.62 -> 10062)
|
||||
*
|
||||
* which keeps increasing as long as patch stays below 10000 and minor below
|
||||
* 1000 (scripts/bump-version.mjs, which only ever bumps patch, enforces both),
|
||||
* and stays under Android's 2100000000 ceiling up to major 209.
|
||||
*/
|
||||
def versionParts = (appVersionName =~ /^(\d+)\.(\d+)\.(\d+)$/)
|
||||
if (!versionParts.matches()) {
|
||||
throw new GradleException("VERSION must be MAJOR.MINOR.PATCH, got '${appVersionName}'")
|
||||
}
|
||||
def (versionMajor, versionMinor, versionPatch) = (1..3).collect { versionParts.group(it) as long }
|
||||
if (versionMinor > 999 || versionPatch > 9999) {
|
||||
throw new GradleException("VERSION ${appVersionName}: minor must stay below 1000 and patch below 10000, " +
|
||||
"or versionCode stops increasing. Move to the next minor or major version instead.")
|
||||
}
|
||||
def appVersionCode = versionMajor * 10000000L + versionMinor * 10000L + versionPatch
|
||||
if (appVersionCode < 1 || appVersionCode > 2100000000L) {
|
||||
throw new GradleException("VERSION ${appVersionName} gives versionCode ${appVersionCode}, " +
|
||||
"outside the 1..2100000000 range Android accepts.")
|
||||
}
|
||||
|
||||
/**
|
||||
* Release signing. The key never lives in the repo: CI passes it as
|
||||
* ORG_GRADLE_PROJECT_BULWARK_RELEASE_* environment variables, and a local
|
||||
* release build can set the same names with -P, in ~/.gradle/gradle.properties
|
||||
* or as plain environment variables. See docs/android-release.md.
|
||||
*
|
||||
* There is deliberately no automatic fallback to the debug key. It is the
|
||||
* public React Native template key, so an APK signed with it can be replaced
|
||||
* by an "update" anyone can sign.
|
||||
*/
|
||||
def releaseSigningNames = [
|
||||
'BULWARK_RELEASE_STORE_FILE',
|
||||
'BULWARK_RELEASE_STORE_PASSWORD',
|
||||
'BULWARK_RELEASE_KEY_ALIAS',
|
||||
'BULWARK_RELEASE_KEY_PASSWORD',
|
||||
]
|
||||
def releaseSigning = releaseSigningNames.collectEntries { name ->
|
||||
[(name): (findProperty(name) ?: System.getenv(name))?.toString()]
|
||||
}
|
||||
def missingReleaseSigning = releaseSigningNames.findAll { !releaseSigning[it]?.trim() }
|
||||
def releaseSigningConfigured = missingReleaseSigning.isEmpty()
|
||||
def releaseStoreFile = releaseSigningConfigured ? file(releaseSigning.BULWARK_RELEASE_STORE_FILE.trim()) : null
|
||||
|
||||
// Escape hatch for a local release build that never leaves the machine
|
||||
// (profiling, checking R8). It is refused whenever CI is set, so a stray flag
|
||||
// cannot turn a published build back into a debug-signed one.
|
||||
def ciEnv = System.getenv('CI')?.trim()?.toLowerCase()
|
||||
def onCi = ciEnv && !(ciEnv in ['false', '0'])
|
||||
def allowDebugSignedRelease = (findProperty('allowDebugSignedRelease') ?: 'false').toString().trim().toBoolean()
|
||||
def debugSignedRelease = !releaseSigningConfigured && allowDebugSignedRelease && !onCi
|
||||
|
||||
/**
|
||||
* This is the configuration block to customize your React Native Android app.
|
||||
* By default you don't need to apply any configuration, just uncomment the lines you need.
|
||||
@@ -95,9 +151,14 @@ android {
|
||||
applicationId 'com.anonymous.bulwarkmobile'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionCode appVersionCode.toInteger()
|
||||
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'}\""
|
||||
}
|
||||
signingConfigs {
|
||||
@@ -107,13 +168,12 @@ android {
|
||||
keyAlias 'androiddebugkey'
|
||||
keyPassword 'android'
|
||||
}
|
||||
if (project.hasProperty('BULWARK_RELEASE_STORE_FILE')
|
||||
&& project.property('BULWARK_RELEASE_STORE_FILE')?.trim()) {
|
||||
if (releaseSigningConfigured) {
|
||||
release {
|
||||
storeFile file(project.property('BULWARK_RELEASE_STORE_FILE'))
|
||||
storePassword project.property('BULWARK_RELEASE_STORE_PASSWORD')
|
||||
keyAlias project.property('BULWARK_RELEASE_KEY_ALIAS')
|
||||
keyPassword project.property('BULWARK_RELEASE_KEY_PASSWORD')
|
||||
storeFile releaseStoreFile
|
||||
storePassword releaseSigning.BULWARK_RELEASE_STORE_PASSWORD
|
||||
keyAlias releaseSigning.BULWARK_RELEASE_KEY_ALIAS
|
||||
keyPassword releaseSigning.BULWARK_RELEASE_KEY_PASSWORD
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,7 +182,13 @@ android {
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
release {
|
||||
signingConfig signingConfigs.findByName('release') ?: signingConfigs.debug
|
||||
// With neither a key nor the opt-in there is no signing config at
|
||||
// all; checkReleaseSigning below stops the build before packaging.
|
||||
if (releaseSigningConfigured) {
|
||||
signingConfig signingConfigs.release
|
||||
} else if (debugSignedRelease) {
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
|
||||
shrinkResources enableShrinkResources.toBoolean()
|
||||
minifyEnabled enableMinifyInReleaseBuilds
|
||||
@@ -142,6 +208,48 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// Stop a release build that has no real key. This runs only when a release
|
||||
// variant is actually built, so `expo run:android`, assembleDebug and IDE sync
|
||||
// keep working with no key configured.
|
||||
def releaseSigningHelp = [
|
||||
"Release signing is not configured (missing: ${missingReleaseSigning.join(', ')}).",
|
||||
"",
|
||||
"Release builds are never signed with the debug key: it is the public React Native",
|
||||
"template key, so an APK signed with it can be replaced by anyone. Provide the release",
|
||||
"key as gradle properties (-P or ~/.gradle/gradle.properties) or environment variables:",
|
||||
" BULWARK_RELEASE_STORE_FILE path to the keystore",
|
||||
" BULWARK_RELEASE_STORE_PASSWORD",
|
||||
" BULWARK_RELEASE_KEY_ALIAS",
|
||||
" BULWARK_RELEASE_KEY_PASSWORD",
|
||||
"",
|
||||
onCi && allowDebugSignedRelease
|
||||
? "allowDebugSignedRelease is ignored because CI is set: published builds must use the release key."
|
||||
: "For a local test build that is never installed anywhere else, pass -PallowDebugSignedRelease=true.",
|
||||
"See docs/android-release.md.",
|
||||
].join('\n')
|
||||
|
||||
def checkReleaseSigning = tasks.register('checkReleaseSigning') {
|
||||
group = 'verification'
|
||||
description = 'Fails a release build that has no release signing key.'
|
||||
doLast {
|
||||
if (releaseSigningConfigured) {
|
||||
if (!releaseStoreFile.isFile()) {
|
||||
throw new GradleException("BULWARK_RELEASE_STORE_FILE points to ${releaseStoreFile}, which does not exist.")
|
||||
}
|
||||
} else if (debugSignedRelease) {
|
||||
logger.warn("WARNING: this release build is signed with the PUBLIC debug key " +
|
||||
"(allowDebugSignedRelease). Do not distribute it.")
|
||||
} else {
|
||||
throw new GradleException(releaseSigningHelp)
|
||||
}
|
||||
}
|
||||
}
|
||||
tasks.configureEach { task ->
|
||||
if (task.name == 'preReleaseBuild') {
|
||||
task.dependsOn(checkReleaseSigning)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply static values from `gradle.properties` to the `android.packagingOptions`
|
||||
// Accepts values in comma delimited lists, example:
|
||||
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
|
||||
@@ -171,6 +279,17 @@ dependencies {
|
||||
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
|
||||
implementation("com.google.firebase:firebase-messaging")
|
||||
|
||||
// UnifiedPush - Google-free push via a user-chosen distributor app such
|
||||
// as ntfy. Coexists with FCM; the user picks the transport in settings.
|
||||
// NOTE: 3.3.2-3.3.4 are built with Kotlin 2.3 and break this project's
|
||||
// Kotlin 2.1 toolchain; 3.3.5 went back to 2.2 which it can read.
|
||||
implementation("org.unifiedpush.android:connector:3.3.5")
|
||||
|
||||
// JVM unit tests for the logic in the Kotlin shell of device sync (src/test).
|
||||
// android.jar only stubs org.json, so the tests use the real one.
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.json:json:20240303")
|
||||
|
||||
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
|
||||
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
|
||||
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
|
||||
|
||||
@@ -12,3 +12,12 @@
|
||||
-keep class com.facebook.react.turbomodule.** { *; }
|
||||
|
||||
# Add any project specific keep options here:
|
||||
|
||||
# The app's own native code (com.anonymous.bulwarkmobile). What R8 must not
|
||||
# drop or rename there is already covered elsewhere: React Native's rules keep
|
||||
# every NativeModule, whose @ReactMethod methods JS calls by name, and AAPT
|
||||
# keeps the application, activity and services listed in AndroidManifest.xml
|
||||
# (FCM, UnifiedPush and the headless JS task). Keeping the package whole on
|
||||
# top of that costs a few KB and leaves its class and method names readable
|
||||
# in a crash report from an obfuscated release build.
|
||||
-keep class com.anonymous.bulwarkmobile.** { *; }
|
||||
|
||||
@@ -3,5 +3,8 @@
|
||||
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
|
||||
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
||||
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic">
|
||||
<!-- adb hooks for the device sync tests (#34). DUMP: only the shell may send them. -->
|
||||
<receiver android:name=".sync.DeviceSyncDebugReceiver" android:exported="true" android:permission="android.permission.DUMP" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/**
|
||||
* Debug builds only: lets adb set up device sync accounts and drive the
|
||||
* provider bridge without the settings UI or JS, for the device tests in
|
||||
* docs/device-sync.md ("Testing"). Protected by android.permission.DUMP,
|
||||
* which only the shell and the system hold. Syncs themselves are triggered
|
||||
* with `adb shell requestsync`.
|
||||
*
|
||||
* adb shell am broadcast -n com.anonymous.bulwarkmobile/.sync.DeviceSyncDebugReceiver \
|
||||
* --es cmd ensure --es name usera@example.org --es registry usera@example.org@10.0.2.2 \
|
||||
* --es enable contacts,calendar
|
||||
*
|
||||
* Commands: `ensure` (name, registry, optional enable), `enable` (name,
|
||||
* authority, optional --ez on false), `remove` (name), `list`, and the
|
||||
* provider calls `query` and `batch` (name, authority, and the
|
||||
* `ProviderQuery`/`ProviderOp[]` JSON base64-encoded in `b64`, which spares
|
||||
* the shell's quoting, or pushed to the app's external files directory and
|
||||
* named by `file` when it is too long for a command line), `syncstate`
|
||||
* (name, authority) and `photo` (name, `--el id`, optional `--ei max`); and
|
||||
* the sync-problem notification, `problem` (name, title, text, uri, optional
|
||||
* channel) and `clearproblem` (name). Results go to logcat (tag
|
||||
* BulwarkDeviceSync) and the broadcast's result data.
|
||||
*/
|
||||
class DeviceSyncDebugReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val pending = goAsync()
|
||||
// Provider calls block; keep them off the main thread.
|
||||
thread(name = "DeviceSyncDebugReceiver") {
|
||||
val result = try {
|
||||
run(context, intent)
|
||||
} catch (e: Exception) {
|
||||
"error: ${e.javaClass.simpleName}: ${e.message}"
|
||||
}
|
||||
Log.i(TAG, "debug command: $result")
|
||||
pending.resultData = result
|
||||
pending.finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun run(context: Context, intent: Intent): String {
|
||||
val name = intent.getStringExtra("name")
|
||||
return when (intent.getStringExtra("cmd")) {
|
||||
"ensure" -> {
|
||||
val created = DeviceSyncAccounts.ensure(context, name!!, intent.getStringExtra("registry")!!)
|
||||
for (which in intent.getStringExtra("enable").orEmpty().split(',').filter { it.isNotBlank() }) {
|
||||
DeviceSyncAccounts.setSyncEnabled(context, name, authority(which), true)
|
||||
}
|
||||
"created=$created"
|
||||
}
|
||||
"enable" -> {
|
||||
val on = intent.getBooleanExtra("on", true)
|
||||
DeviceSyncAccounts.setSyncEnabled(context, name!!, authority(intent.getStringExtra("authority")!!), on)
|
||||
"enabled=$on"
|
||||
}
|
||||
"remove" -> "removed=${DeviceSyncAccounts.remove(context, name!!)}"
|
||||
"list" -> DeviceSyncAccounts.list(context).joinToString(";") { (account, registryId) -> "${account.name}=$registryId" }
|
||||
"query" -> io(context, intent) { it.query(json(context, intent)) }
|
||||
"batch" -> io(context, intent) { it.applyBatch(json(context, intent)) }
|
||||
"syncstate" -> io(context, intent) { it.readSyncState().toString() }
|
||||
"photo" -> ProviderIo(context, name!!, DeviceSyncAccounts.CONTACTS_AUTHORITY).use {
|
||||
it.readPhoto(intent.getLongExtra("id", -1), intent.getIntExtra("max", 512)).toString()
|
||||
}
|
||||
"problem" -> {
|
||||
SyncProblemNotifier.show(
|
||||
context,
|
||||
name!!,
|
||||
intent.getStringExtra("title").orEmpty(),
|
||||
intent.getStringExtra("text").orEmpty(),
|
||||
intent.getStringExtra("uri").orEmpty(),
|
||||
intent.getStringExtra("channel"),
|
||||
)
|
||||
"shown"
|
||||
}
|
||||
"clearproblem" -> {
|
||||
SyncProblemNotifier.clear(context, name!!)
|
||||
"cleared"
|
||||
}
|
||||
else -> "unknown cmd"
|
||||
}
|
||||
}
|
||||
|
||||
private fun io(context: Context, intent: Intent, call: (ProviderIo) -> String): String =
|
||||
ProviderIo(context, intent.getStringExtra("name")!!, authority(intent.getStringExtra("authority")!!)).use(call)
|
||||
|
||||
private fun json(context: Context, intent: Intent): String {
|
||||
intent.getStringExtra("file")?.let { return File(context.getExternalFilesDir(null), it).readText() }
|
||||
return String(Base64.decode(intent.getStringExtra("b64")!!, Base64.DEFAULT), Charsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun authority(which: String) = when (which) {
|
||||
"contacts" -> DeviceSyncAccounts.CONTACTS_AUTHORITY
|
||||
"calendar" -> DeviceSyncAccounts.CALENDAR_AUTHORITY
|
||||
else -> which
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BulwarkDeviceSync"
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<!-- 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.WRITE_CONTACTS"/>
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR"/>
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR"/>
|
||||
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS"/>
|
||||
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS"/>
|
||||
<uses-permission android:name="android.permission.READ_SYNC_STATS"/>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
@@ -13,15 +21,52 @@
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
</queries>
|
||||
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" 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">
|
||||
<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_LAUNCH_WAIT_MS" android:value="0"/>
|
||||
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
|
||||
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<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="bulwarkmobile"/>
|
||||
</intent-filter>
|
||||
<!-- Sign-in links from the webmail (bulwarkmail://pair?..., bulwarkmail://connect?...). -->
|
||||
<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="bulwarkmail"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<action android:name="android.intent.action.SENDTO"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="mailto"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:mimeType="text/*"/>
|
||||
<data android:mimeType="image/*"/>
|
||||
<data android:mimeType="application/*"/>
|
||||
<data android:mimeType="audio/*"/>
|
||||
<data android:mimeType="video/*"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:mimeType="image/*"/>
|
||||
<data android:mimeType="application/*"/>
|
||||
<data android:mimeType="audio/*"/>
|
||||
<data android:mimeType="video/*"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service android:name=".BulwarkMessagingService" android:exported="false">
|
||||
<intent-filter>
|
||||
@@ -29,6 +74,263 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
<service android:name=".BulwarkPushTaskService" android:exported="false"/>
|
||||
<service android:name=".BulwarkWidgetRefreshService" android:exported="false"/>
|
||||
<service android:name=".BulwarkPushJobService" android:permission="android.permission.BIND_JOB_SERVICE" android:exported="false"/>
|
||||
<receiver android:name=".BulwarkNotificationActionReceiver" android:exported="false"/>
|
||||
<service android:name=".BulwarkUnifiedPushService" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
<meta-data android:name="com.google.firebase.messaging.default_notification_channel_id" android:value="bulwark_mail"/>
|
||||
<!-- Device sync (#34), see docs/device-sync.md. The system binds these, so they are exported.
|
||||
They run in the app's main process on purpose: the adapters start the sync engine as a
|
||||
headless JS task in the same React Native runtime as the UI. -->
|
||||
<service android:name=".sync.BulwarkAuthenticatorService" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.AccountAuthenticator"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.accounts.AccountAuthenticator" android:resource="@xml/authenticator"/>
|
||||
</service>
|
||||
<service android:name=".sync.ContactsSyncService" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.content.SyncAdapter" android:resource="@xml/sync_contacts"/>
|
||||
<meta-data android:name="android.provider.CONTACTS_STRUCTURE" android:resource="@xml/contacts"/>
|
||||
</service>
|
||||
<service android:name=".sync.CalendarSyncService" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.content.SyncAdapter" android:resource="@xml/sync_calendar"/>
|
||||
</service>
|
||||
<!-- BEGIN WIDGETS: generated by scripts/sync-android-widgets.mjs, do not edit by hand -->
|
||||
<service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/>
|
||||
<receiver android:name=".widget.InboxWidget" android:exported="false" android:label="@string/widgetprovider_inboxwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_inboxwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TriageWidget" android:exported="false" android:label="@string/widgetprovider_triagewidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_triagewidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.UnreadCountWidget" android:exported="false" android:label="@string/widgetprovider_unreadcountwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_unreadcountwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.LatestMessageWidget" android:exported="false" android:label="@string/widgetprovider_latestmessagewidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_latestmessagewidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.FolderCountsWidget" android:exported="false" android:label="@string/widgetprovider_foldercountswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_foldercountswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.MailShortcutsWidget" android:exported="false" android:label="@string/widgetprovider_mailshortcutswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailshortcutswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.AllAccountsWidget" android:exported="false" android:label="@string/widgetprovider_allaccountswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_allaccountswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.StarredWidget" android:exported="false" android:label="@string/widgetprovider_starredwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_starredwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.FavouritePeopleWidget" android:exported="false" android:label="@string/widgetprovider_favouritepeoplewidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_favouritepeoplewidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.MailSearchWidget" android:exported="false" android:label="@string/widgetprovider_mailsearchwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailsearchwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.OutboxWidget" android:exported="false" android:label="@string/widgetprovider_outboxwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_outboxwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TagWidget" android:exported="false" android:label="@string/widgetprovider_tagwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_tagwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.LastDraftWidget" android:exported="false" android:label="@string/widgetprovider_lastdraftwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_lastdraftwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.AgendaWidget" android:exported="false" android:label="@string/widgetprovider_agendawidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_agendawidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.WeekWidget" android:exported="false" android:label="@string/widgetprovider_weekwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_weekwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.MonthWidget" android:exported="false" android:label="@string/widgetprovider_monthwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_monthwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TodayEventsWidget" android:exported="false" android:label="@string/widgetprovider_todayeventswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayeventswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.NextEventWidget" android:exported="false" android:label="@string/widgetprovider_nexteventwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_nexteventwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.DateNextWidget" android:exported="false" android:label="@string/widgetprovider_datenextwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_datenextwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.UpNextWidget" android:exported="false" android:label="@string/widgetprovider_upnextwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_upnextwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.InvitationsWidget" android:exported="false" android:label="@string/widgetprovider_invitationswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_invitationswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.FreeTimeWidget" android:exported="false" android:label="@string/widgetprovider_freetimewidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_freetimewidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TasksWidget" android:exported="false" android:label="@string/widgetprovider_taskswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TaskProgressWidget" android:exported="false" android:label="@string/widgetprovider_taskprogresswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_taskprogresswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.CountdownWidget" android:exported="false" android:label="@string/widgetprovider_countdownwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_countdownwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.BirthdaysWidget" android:exported="false" android:label="@string/widgetprovider_birthdayswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_birthdayswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.TodayHubWidget" android:exported="false" android:label="@string/widgetprovider_todayhubwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_todayhubwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.MailAndNextWidget" android:exported="false" android:label="@string/widgetprovider_mailandnextwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_mailandnextwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.RecentFilesWidget" android:exported="false" android:label="@string/widgetprovider_recentfileswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_recentfileswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.AttachmentsWidget" android:exported="false" android:label="@string/widgetprovider_attachmentswidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_attachmentswidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.VacationWidget" android:exported="false" android:label="@string/widgetprovider_vacationwidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_vacationwidget"/>
|
||||
</receiver>
|
||||
<receiver android:name=".widget.StorageWidget" android:exported="false" android:label="@string/widgetprovider_storagewidget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.anonymous.bulwarkmobile.WIDGET_CLICK"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_storagewidget"/>
|
||||
</receiver>
|
||||
<!-- END WIDGETS -->
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,288 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.security.KeyChain
|
||||
import android.security.KeyChainAliasCallback
|
||||
import android.util.Base64
|
||||
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 com.facebook.react.bridge.ReadableMap
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.Socket
|
||||
import java.net.URL
|
||||
import java.security.Principal
|
||||
import java.security.PrivateKey
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLEngine
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509ExtendedKeyManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/**
|
||||
* Bridges Android's KeyChain client-certificate machinery into JS so the
|
||||
* mobile app can authenticate to a reverse proxy that asks for mTLS.
|
||||
*
|
||||
* Two surfaces:
|
||||
* 1. `pickAlias` / `getAlias` / `clearAlias` — UI flow that lets the user
|
||||
* pick a cert installed in Android's system credential store. The
|
||||
* selected alias is persisted in SharedPreferences so the choice
|
||||
* survives app restarts.
|
||||
* 2. `fetchSecure` — a fetch shim built on `HttpsURLConnection` that
|
||||
* injects the picked cert into the TLS handshake. JS-side `fetch`
|
||||
* can't present a client cert because RN's bundled OkHttp client
|
||||
* doesn't expose its KeyManager hook to userland; this method is
|
||||
* the workaround.
|
||||
*/
|
||||
class BulwarkClientCertModule(reactContext: ReactApplicationContext)
|
||||
: ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
@Volatile private var cachedAlias: String? = prefs.getString(PREF_ALIAS, null)
|
||||
@Volatile private var cachedFactory: SSLSocketFactory? = null
|
||||
@Volatile private var cachedFactoryForAlias: String? = null
|
||||
|
||||
override fun getName(): String = MODULE_NAME
|
||||
|
||||
// ── alias management ────────────────────────────────────────
|
||||
|
||||
@ReactMethod
|
||||
fun getAlias(promise: Promise) {
|
||||
promise.resolve(cachedAlias)
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun clearAlias(promise: Promise) {
|
||||
prefs.edit().remove(PREF_ALIAS).apply()
|
||||
cachedAlias = null
|
||||
invalidateFactory()
|
||||
promise.resolve(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the system "Choose a certificate" picker. Resolves with the
|
||||
* selected alias, or `null` when the user dismisses the picker.
|
||||
*/
|
||||
@ReactMethod
|
||||
fun pickAlias(host: String?, promise: Promise) {
|
||||
// ReactContextBaseJavaModule no longer exposes a Kotlin synthetic
|
||||
// `currentActivity` property in RN 0.80+ (compiles as an unresolved
|
||||
// reference). The explicit Java-style getter on the application
|
||||
// context is the supported call site.
|
||||
val activity: android.app.Activity? = reactApplicationContext.getCurrentActivity()
|
||||
if (activity == null) {
|
||||
promise.reject("no_activity", "No current activity to host the cert picker")
|
||||
return
|
||||
}
|
||||
val callback = KeyChainAliasCallback { alias ->
|
||||
// Callback runs off the main thread; persist + resolve directly.
|
||||
if (alias.isNullOrBlank()) {
|
||||
promise.resolve(null)
|
||||
return@KeyChainAliasCallback
|
||||
}
|
||||
prefs.edit().putString(PREF_ALIAS, alias).apply()
|
||||
cachedAlias = alias
|
||||
invalidateFactory()
|
||||
promise.resolve(alias)
|
||||
}
|
||||
try {
|
||||
KeyChain.choosePrivateKeyAlias(
|
||||
activity,
|
||||
callback,
|
||||
/* keyTypes */ arrayOf("RSA", "EC"),
|
||||
/* issuers */ null,
|
||||
/* host */ host,
|
||||
/* port */ -1,
|
||||
/* alias */ cachedAlias,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("pick_failed", e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
// ── secure fetch ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Perform an HTTP request that presents the persisted client cert (if
|
||||
* any) during the TLS handshake.
|
||||
*
|
||||
* @param request {url, method, headers: {k:v}, bodyBase64: string|null, timeoutMs: number}
|
||||
*
|
||||
* Resolves with `{status, statusText, headers, bodyBase64}` so JS can
|
||||
* reconstruct a standard `Response`-like object.
|
||||
*/
|
||||
@ReactMethod
|
||||
fun fetchSecure(request: ReadableMap, promise: Promise) {
|
||||
val url = request.getString("url")
|
||||
if (url.isNullOrBlank()) {
|
||||
promise.reject("bad_args", "url is required")
|
||||
return
|
||||
}
|
||||
val method = (request.getString("method") ?: "GET").uppercase()
|
||||
val timeoutMs = if (request.hasKey("timeoutMs")) request.getInt("timeoutMs") else 30_000
|
||||
val headersMap = request.getMap("headers")
|
||||
val bodyBase64 = request.getString("bodyBase64")
|
||||
val bodyBytes = bodyBase64?.let { Base64.decode(it, Base64.NO_WRAP) }
|
||||
|
||||
thread(name = "BulwarkClientCert.fetch") {
|
||||
try {
|
||||
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = timeoutMs
|
||||
readTimeout = timeoutMs
|
||||
requestMethod = method
|
||||
// Redirects are resolved in JS (lib/client-cert.ts) so a POST
|
||||
// stays a POST and credentials never follow a cross-host hop.
|
||||
instanceFollowRedirects = false
|
||||
if (bodyBytes != null) {
|
||||
doOutput = true
|
||||
setFixedLengthStreamingMode(bodyBytes.size)
|
||||
}
|
||||
}
|
||||
if (conn is HttpsURLConnection && cachedAlias != null) {
|
||||
conn.sslSocketFactory = buildOrReuseSocketFactory()
|
||||
}
|
||||
headersMap?.let { h ->
|
||||
val it = h.keySetIterator()
|
||||
while (it.hasNextKey()) {
|
||||
val k = it.nextKey()
|
||||
val v = h.getString(k)
|
||||
if (v != null) conn.setRequestProperty(k, v)
|
||||
}
|
||||
}
|
||||
conn.connect()
|
||||
if (bodyBytes != null) {
|
||||
conn.outputStream.use { out -> out.write(bodyBytes) }
|
||||
}
|
||||
|
||||
val status = conn.responseCode
|
||||
val statusText = conn.responseMessage ?: ""
|
||||
val responseStream = try {
|
||||
conn.inputStream
|
||||
} catch (_: Exception) {
|
||||
conn.errorStream
|
||||
}
|
||||
val responseBytes = if (responseStream != null) {
|
||||
val buf = ByteArrayOutputStream()
|
||||
responseStream.copyTo(buf)
|
||||
responseStream.close()
|
||||
buf.toByteArray()
|
||||
} else {
|
||||
ByteArray(0)
|
||||
}
|
||||
|
||||
val out = Arguments.createMap()
|
||||
out.putInt("status", status)
|
||||
out.putString("statusText", statusText)
|
||||
val headers = Arguments.createMap()
|
||||
// headerFields is a `Map<String?, List<String>>`; the null
|
||||
// key is the HTTP status line, which we don't need to copy.
|
||||
for ((name, values) in conn.headerFields) {
|
||||
if (name == null || values == null) continue
|
||||
headers.putString(name, values.joinToString(", "))
|
||||
}
|
||||
out.putMap("headers", headers)
|
||||
out.putString("bodyBase64", Base64.encodeToString(responseBytes, Base64.NO_WRAP))
|
||||
conn.disconnect()
|
||||
promise.resolve(out)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("fetch_failed", e.message ?: e.javaClass.simpleName, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── internals ───────────────────────────────────────────────
|
||||
|
||||
private fun invalidateFactory() {
|
||||
cachedFactory = null
|
||||
cachedFactoryForAlias = null
|
||||
}
|
||||
|
||||
private fun buildOrReuseSocketFactory(): SSLSocketFactory {
|
||||
val alias = cachedAlias ?: throw IllegalStateException("No client-cert alias selected")
|
||||
val existing = cachedFactory
|
||||
if (existing != null && cachedFactoryForAlias == alias) return existing
|
||||
|
||||
val ctx = reactApplicationContext
|
||||
val km = KeyChainKeyManager(ctx, alias)
|
||||
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
tmf.init(null as java.security.KeyStore?)
|
||||
val trustManagers = tmf.trustManagers
|
||||
val tm = trustManagers.firstOrNull { it is X509TrustManager } as X509TrustManager?
|
||||
?: throw IllegalStateException("No X509TrustManager available")
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(arrayOf(km), arrayOf<TrustManager>(tm), null)
|
||||
val factory = sslContext.socketFactory
|
||||
cachedFactory = factory
|
||||
cachedFactoryForAlias = alias
|
||||
return factory
|
||||
}
|
||||
|
||||
/**
|
||||
* X509ExtendedKeyManager that always returns the same KeyChain alias.
|
||||
* KeyChain.getPrivateKey / getCertificateChain are blocking; we call
|
||||
* them lazily during the TLS handshake, which is fine since the
|
||||
* fetch already runs off-main-thread.
|
||||
*/
|
||||
private class KeyChainKeyManager(
|
||||
private val context: Context,
|
||||
private val alias: String,
|
||||
) : X509ExtendedKeyManager() {
|
||||
|
||||
override fun chooseClientAlias(
|
||||
keyTypes: Array<out String>?,
|
||||
issuers: Array<out Principal>?,
|
||||
socket: Socket?,
|
||||
): String = alias
|
||||
|
||||
override fun chooseEngineClientAlias(
|
||||
keyType: Array<out String>?,
|
||||
issuers: Array<out Principal>?,
|
||||
engine: SSLEngine?,
|
||||
): String = alias
|
||||
|
||||
override fun getCertificateChain(alias: String?): Array<X509Certificate>? {
|
||||
return try {
|
||||
KeyChain.getCertificateChain(context, this.alias)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPrivateKey(alias: String?): PrivateKey? {
|
||||
return try {
|
||||
KeyChain.getPrivateKey(context, this.alias)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun getClientAliases(keyType: String?, issuers: Array<out Principal>?): Array<String> =
|
||||
arrayOf(alias)
|
||||
|
||||
override fun getServerAliases(keyType: String?, issuers: Array<out Principal>?): Array<String> =
|
||||
emptyArray()
|
||||
|
||||
override fun chooseServerAlias(
|
||||
keyType: String?,
|
||||
issuers: Array<out Principal>?,
|
||||
socket: Socket?,
|
||||
): String? = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MODULE_NAME = "BulwarkClientCert"
|
||||
private const val PREFS_NAME = "bulwark_client_cert"
|
||||
private const val PREF_ALIAS = "alias"
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
@@ -19,6 +20,7 @@ import com.facebook.react.bridge.ReadableMap
|
||||
import com.facebook.react.bridge.WritableMap
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlin.concurrent.thread
|
||||
@@ -64,6 +66,17 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
val emailId = options.getString("emailId")
|
||||
val threadId = options.getString("threadId")
|
||||
val subject = options.takeIf { it.hasKey("subject") }?.getString("subject")
|
||||
val accountId = options.takeIf { it.hasKey("accountId") }?.getString("accountId")
|
||||
val jmapAccountId = options.takeIf { it.hasKey("jmapAccountId") }?.getString("jmapAccountId")
|
||||
val groupKey = options.takeIf { it.hasKey("groupKey") }?.getString("groupKey")
|
||||
?: accountId?.let { "bulwark-mail:$it" }
|
||||
val groupTitle = options.takeIf { it.hasKey("groupTitle") }?.getString("groupTitle")
|
||||
?: accountId ?: "Bulwark Mail"
|
||||
|
||||
val preview = options.takeIf { it.hasKey("preview") }?.getString("preview")
|
||||
val markReadLabel = options.takeIf { it.hasKey("markReadLabel") }?.getString("markReadLabel") ?: "Mark as read"
|
||||
val deleteLabel = options.takeIf { it.hasKey("deleteLabel") }?.getString("deleteLabel") ?: "Delete"
|
||||
val replyLabel = options.takeIf { it.hasKey("replyLabel") }?.getString("replyLabel") ?: "Reply"
|
||||
|
||||
// Bitmap fetch + draw off the bridge thread so the caller doesn't
|
||||
// block waiting for the favicon request.
|
||||
@@ -71,9 +84,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
val largeIcon = iconUrl?.let { fetchBitmap(it) }
|
||||
?: makeLetterAvatar(initials, bgColorHex)
|
||||
postNotification(
|
||||
notificationId, title, body, largeIcon, bgColorHex,
|
||||
emailId, threadId, subject,
|
||||
notificationId, title, body, preview, largeIcon, bgColorHex,
|
||||
emailId, threadId, subject, accountId, jmapAccountId, groupKey,
|
||||
groupTitle, markReadLabel, deleteLabel, replyLabel,
|
||||
)
|
||||
if (groupKey != null) postGroupSummary(groupKey, groupTitle, bgColorHex, accountId)
|
||||
promise.resolve(null)
|
||||
}
|
||||
}
|
||||
@@ -84,22 +99,39 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
promise.resolve(payload?.toMap())
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun getInitialShare(promise: Promise) {
|
||||
val payload = ShareIntentStore.consume()
|
||||
promise.resolve(payload?.toMap())
|
||||
}
|
||||
|
||||
private fun postNotification(
|
||||
notificationId: String,
|
||||
title: String,
|
||||
body: String,
|
||||
preview: String?,
|
||||
largeIcon: Bitmap,
|
||||
colorHex: String,
|
||||
emailId: String?,
|
||||
threadId: String?,
|
||||
subject: String?,
|
||||
accountId: String?,
|
||||
jmapAccountId: String?,
|
||||
groupKey: String?,
|
||||
groupTitle: String?,
|
||||
markReadLabel: String,
|
||||
deleteLabel: String,
|
||||
replyLabel: String,
|
||||
) {
|
||||
val ctx = reactApplicationContext
|
||||
val intent = Intent(ctx, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
data = Uri.parse("bulwark-notification://$notificationId")
|
||||
if (emailId != null) putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
|
||||
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
|
||||
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
}
|
||||
val pending = PendingIntent.getActivity(
|
||||
ctx,
|
||||
@@ -108,21 +140,160 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val bigText = buildString {
|
||||
if (!subject.isNullOrBlank()) append(subject)
|
||||
if (!preview.isNullOrBlank()) {
|
||||
if (isNotEmpty()) append("\n")
|
||||
append(preview)
|
||||
}
|
||||
}
|
||||
|
||||
val builder = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_email)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setLargeIcon(largeIcon)
|
||||
.setContentTitle(title)
|
||||
// body is the subject with the "(No subject)" fallback; an empty
|
||||
// subject is "" here, not null, so `subject ?: ...` showed a blank line.
|
||||
.setContentText(body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setStyle(
|
||||
NotificationCompat.BigTextStyle()
|
||||
.bigText(if (bigText.isNotBlank()) bigText else body)
|
||||
.setBigContentTitle(title)
|
||||
.setSummaryText(groupTitle)
|
||||
)
|
||||
.setColor(parseColor(colorHex, fallback = Color.parseColor("#2563eb")))
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setContentIntent(pending)
|
||||
if (groupKey != null) {
|
||||
builder.setGroup(groupKey)
|
||||
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
}
|
||||
|
||||
if (emailId != null && accountId != null) {
|
||||
// Action 1: Mark as read
|
||||
val markReadIntent = Intent(ctx, BulwarkNotificationActionReceiver::class.java).apply {
|
||||
action = ACTION_MARK_READ
|
||||
data = Uri.parse("bulwark-action-read://$notificationId")
|
||||
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||
putExtra(EXTRA_ACTION, "markRead")
|
||||
putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
}
|
||||
val markReadPending = PendingIntent.getBroadcast(
|
||||
ctx,
|
||||
(notificationId + ":read").hashCode(),
|
||||
markReadIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
builder.addAction(
|
||||
NotificationCompat.Action.Builder(0, markReadLabel, markReadPending).build()
|
||||
)
|
||||
|
||||
// Action 2: Delete
|
||||
val deleteIntent = Intent(ctx, BulwarkNotificationActionReceiver::class.java).apply {
|
||||
action = ACTION_DELETE
|
||||
data = Uri.parse("bulwark-action-delete://$notificationId")
|
||||
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||
putExtra(EXTRA_ACTION, "delete")
|
||||
putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
}
|
||||
val deletePending = PendingIntent.getBroadcast(
|
||||
ctx,
|
||||
(notificationId + ":delete").hashCode(),
|
||||
deleteIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
builder.addAction(
|
||||
NotificationCompat.Action.Builder(0, deleteLabel, deletePending)
|
||||
// Android 12+: unlock first, so a locked phone can't delete mail.
|
||||
.setAuthenticationRequired(true)
|
||||
.build()
|
||||
)
|
||||
|
||||
// Action 3: Reply
|
||||
val replyIntent = Intent(ctx, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
data = Uri.parse("bulwark-reply://$notificationId")
|
||||
putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
|
||||
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
|
||||
putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
putExtra(EXTRA_ACTION, "reply")
|
||||
}
|
||||
val replyPending = PendingIntent.getActivity(
|
||||
ctx,
|
||||
(notificationId + ":reply").hashCode(),
|
||||
replyIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
builder.addAction(
|
||||
NotificationCompat.Action.Builder(0, replyLabel, replyPending).build()
|
||||
)
|
||||
}
|
||||
|
||||
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(notificationId, notificationId.hashCode(), builder.build())
|
||||
}
|
||||
|
||||
// One summary per account group so several deliveries collapse into a
|
||||
// single "N new messages" entry (Android renders the "+N more" itself
|
||||
// from the children). Tapping the summary opens the app on the inbox;
|
||||
// the per-message children carry the deep link.
|
||||
private fun postGroupSummary(groupKey: String, groupTitle: String, colorHex: String, accountId: String?) {
|
||||
val ctx = reactApplicationContext
|
||||
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val children = manager.activeNotifications.filter {
|
||||
it.groupKey?.endsWith(groupKey) == true &&
|
||||
(it.notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY) == 0
|
||||
}
|
||||
if (children.size < 2) {
|
||||
manager.cancel(groupKey, groupKey.hashCode())
|
||||
return
|
||||
}
|
||||
val count = children.size
|
||||
val inbox = NotificationCompat.InboxStyle()
|
||||
children.sortedByDescending { it.postTime }.take(5).forEach { sbn ->
|
||||
val extras = sbn.notification.extras
|
||||
val line = listOfNotNull(
|
||||
extras.getCharSequence(android.app.Notification.EXTRA_TITLE),
|
||||
extras.getCharSequence(android.app.Notification.EXTRA_TEXT),
|
||||
).joinToString(" ")
|
||||
if (line.isNotBlank()) inbox.addLine(line)
|
||||
}
|
||||
inbox.setBigContentTitle(groupTitle)
|
||||
inbox.setSummaryText(if (count == 1) "1 new message" else "$count new messages")
|
||||
|
||||
val intent = Intent(ctx, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
data = Uri.parse("bulwark-group-summary://$groupKey")
|
||||
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
}
|
||||
val pending = PendingIntent.getActivity(
|
||||
ctx,
|
||||
groupKey.hashCode(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val summary = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(groupTitle)
|
||||
.setContentText(if (count == 1) "1 new message" else "$count new messages")
|
||||
.setStyle(inbox)
|
||||
.setColor(parseColor(colorHex, fallback = Color.parseColor("#2563eb")))
|
||||
.setGroup(groupKey)
|
||||
.setGroupSummary(true)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pending)
|
||||
.build()
|
||||
manager.notify(groupKey, groupKey.hashCode(), summary)
|
||||
}
|
||||
|
||||
private fun makeLetterAvatar(initials: String, bgHex: String): Bitmap {
|
||||
val size = 192
|
||||
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
@@ -148,7 +319,36 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
conn.readTimeout = 3000
|
||||
conn.instanceFollowRedirects = true
|
||||
conn.setRequestProperty("User-Agent", "bulwark-mobile")
|
||||
conn.inputStream.use { BitmapFactory.decodeStream(it) }
|
||||
// Cap the response to MAX_FAVICON_BYTES so a hostile / misconfigured
|
||||
// server can't OOM the headless task with a multi-megabyte image.
|
||||
// We also sample down to ~256x256 since it'll only render at 192px.
|
||||
val stream = conn.inputStream
|
||||
val bytes = stream.use { input ->
|
||||
val limit = MAX_FAVICON_BYTES
|
||||
val buf = ByteArrayOutputStream()
|
||||
val chunk = ByteArray(8 * 1024)
|
||||
var total = 0
|
||||
while (true) {
|
||||
val n = input.read(chunk)
|
||||
if (n <= 0) break
|
||||
total += n
|
||||
if (total > limit) return@use null
|
||||
buf.write(chunk, 0, n)
|
||||
}
|
||||
buf.toByteArray()
|
||||
} ?: return null
|
||||
val opts = BitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
}
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)
|
||||
var sample = 1
|
||||
while ((opts.outWidth / sample) > 384 || (opts.outHeight / sample) > 384) {
|
||||
sample *= 2
|
||||
}
|
||||
val decodeOpts = BitmapFactory.Options().apply {
|
||||
inSampleSize = sample
|
||||
}
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOpts)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
@@ -167,8 +367,16 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
fun removeListeners(count: Int) {}
|
||||
|
||||
companion object {
|
||||
// 1 MiB is plenty for a favicon; anything bigger is a sign of trouble.
|
||||
private const val MAX_FAVICON_BYTES = 1 * 1024 * 1024
|
||||
|
||||
@Volatile private var currentInstance: BulwarkFcmModule? = null
|
||||
|
||||
const val EXTRA_ACTION = "bulwark.notification.action"
|
||||
const val EXTRA_NOTIFICATION_ID = "bulwark.notification.notificationId"
|
||||
const val ACTION_MARK_READ = "com.anonymous.bulwarkmobile.ACTION_MARK_READ"
|
||||
const val ACTION_DELETE = "com.anonymous.bulwarkmobile.ACTION_DELETE"
|
||||
|
||||
fun emit(eventName: String, params: WritableMap?) {
|
||||
val module = currentInstance ?: return
|
||||
val ctx = module.reactApplicationContext
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import com.anonymous.bulwarkmobile.sync.BulwarkDeviceSyncModule
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class BulwarkFcmPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
|
||||
listOf(BulwarkFcmModule(reactContext))
|
||||
buildList {
|
||||
add(BulwarkFcmModule(reactContext))
|
||||
add(BulwarkUnifiedPushModule(reactContext))
|
||||
add(BulwarkClientCertModule(reactContext))
|
||||
add(BulwarkDeviceSyncModule(reactContext))
|
||||
add(BulwarkWidgetsModule(reactContext))
|
||||
// The bridge registers React Native's own module under this name.
|
||||
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
||||
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
|
||||
}
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
|
||||
emptyList()
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
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 com.facebook.react.jstasks.HeadlessJsTaskContext
|
||||
|
||||
/**
|
||||
* React Native's HeadlessJsTaskSupport module, which AppRegistry calls when a
|
||||
* headless JS task's promise settles. RN 0.81 registers it only for the old
|
||||
* bridge (CoreModulesPackage); the bridgeless runtime's CoreReactPackage
|
||||
* leaves it out, so in this app JS could never report a finished task and
|
||||
* every BulwarkPushTask ran until its 30 s timeout, keeping the service or job
|
||||
* and its wake lock alive that long. Same logic as the RN module.
|
||||
*/
|
||||
class BulwarkHeadlessJsTaskSupportModule(reactContext: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
override fun getName(): String = "HeadlessJsTaskSupport"
|
||||
|
||||
@ReactMethod
|
||||
fun notifyTaskFinished(taskId: Double) {
|
||||
val tasks = HeadlessJsTaskContext.getInstance(reactApplicationContext)
|
||||
val id = taskId.toInt()
|
||||
if (tasks.isTaskRunning(id)) tasks.finishTask(id)
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun notifyTaskRetry(taskId: Double, promise: Promise) {
|
||||
val tasks = HeadlessJsTaskContext.getInstance(reactApplicationContext)
|
||||
val id = taskId.toInt()
|
||||
promise.resolve(tasks.isTaskRunning(id) && tasks.retryTask(id))
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,10 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.facebook.react.HeadlessJsTaskService
|
||||
import com.anonymous.bulwarkmobile.sync.DeviceSyncPushRouter
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
@@ -25,13 +27,17 @@ class BulwarkMessagingService : FirebaseMessagingService() {
|
||||
|
||||
val data = message.data
|
||||
|
||||
// Contact and calendar changes become syncs of the Android accounts
|
||||
// they feed (device sync, #34); only mail needs the push task.
|
||||
val mail = DeviceSyncPushRouter.route(applicationContext, data)
|
||||
|
||||
// Hand off to JS via a headless task only when the app isn't already
|
||||
// running in the foreground - HeadlessJsTaskContext throws if started
|
||||
// while foreground. When the app is open, the main JS instance already
|
||||
// receives JMAP push directly, so the fcm:message event below is
|
||||
// sufficient for it to refresh state.
|
||||
if (!isAppInForeground()) {
|
||||
startHeadlessTask(data)
|
||||
if (mail && !isAppInForeground()) {
|
||||
startHeadlessTask(applicationContext, data)
|
||||
}
|
||||
|
||||
val params = Arguments.createMap().apply {
|
||||
@@ -42,25 +48,60 @@ class BulwarkMessagingService : FirebaseMessagingService() {
|
||||
BulwarkFcmModule.emit("fcm:message", params)
|
||||
}
|
||||
|
||||
private fun isAppInForeground(): Boolean {
|
||||
val info = ActivityManager.RunningAppProcessInfo()
|
||||
ActivityManager.getMyMemoryState(info)
|
||||
return info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND ||
|
||||
info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE
|
||||
}
|
||||
|
||||
private fun startHeadlessTask(data: Map<String, String>) {
|
||||
val intent = Intent(applicationContext, BulwarkPushTaskService::class.java)
|
||||
val bundle = Bundle().apply {
|
||||
for ((k, v) in data) putString(k, v)
|
||||
}
|
||||
intent.putExtras(bundle)
|
||||
applicationContext.startService(intent)
|
||||
HeadlessJsTaskService.acquireWakeLockNow(applicationContext)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "bulwark_mail"
|
||||
private const val TAG = "BulwarkPush"
|
||||
private const val START_WAKE_LOCK_MS = 10_000L
|
||||
|
||||
// Shared with BulwarkUnifiedPushService - a push arriving over either
|
||||
// transport is dispatched the same way.
|
||||
fun isAppInForeground(): Boolean {
|
||||
val info = ActivityManager.RunningAppProcessInfo()
|
||||
ActivityManager.getMyMemoryState(info)
|
||||
return info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND ||
|
||||
info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE
|
||||
}
|
||||
|
||||
fun startHeadlessTask(context: Context, data: Map<String, String>) {
|
||||
val intent = Intent(context, BulwarkPushTaskService::class.java)
|
||||
val bundle = Bundle().apply {
|
||||
for ((k, v) in data) putString(k, v)
|
||||
}
|
||||
intent.putExtras(bundle)
|
||||
// Keep the device awake until the service holds React Native's
|
||||
// wake lock; BulwarkPushTaskService releases this one then. Not
|
||||
// HeadlessJsTaskService.acquireWakeLockNow: it is not
|
||||
// thread-safe, and FCM calls this on a worker thread while the
|
||||
// service takes the same static lock on the main thread, so both
|
||||
// could create a lock and the overwritten one stayed held.
|
||||
startWakeLock = context.getSystemService(PowerManager::class.java)
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "bulwark:push-start")
|
||||
.apply {
|
||||
setReferenceCounted(false)
|
||||
acquire(START_WAKE_LOCK_MS)
|
||||
}
|
||||
try {
|
||||
context.startService(intent)
|
||||
} catch (e: IllegalStateException) {
|
||||
// Android 8+ refuses to start a service from the background
|
||||
// unless the app is temporarily allowlisted. A high-priority
|
||||
// FCM message does that; a downgraded one or a UnifiedPush
|
||||
// message may not (BackgroundServiceStartNotAllowedException
|
||||
// on 12+), and uncaught this crashed the app on every such
|
||||
// push. Run the same task from a job instead.
|
||||
releaseStartWakeLock()
|
||||
Log.w(TAG, "Push service start refused in the background, using a job: ${e.message}")
|
||||
BulwarkPushJobService.schedule(context, data)
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile private var startWakeLock: PowerManager.WakeLock? = null
|
||||
|
||||
/** Drops the wake lock taken in [startHeadlessTask]; it also times out on its own. */
|
||||
fun releaseStartWakeLock() {
|
||||
startWakeLock?.release()
|
||||
startWakeLock = null
|
||||
}
|
||||
|
||||
fun ensureChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.app.Application
|
||||
import android.app.NotificationManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
|
||||
|
||||
class BulwarkNotificationActionReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val notificationId = intent.getStringExtra(BulwarkFcmModule.EXTRA_NOTIFICATION_ID)
|
||||
val action = intent.getStringExtra(BulwarkFcmModule.EXTRA_ACTION)
|
||||
val emailId = intent.getStringExtra(NotificationTapStore.EXTRA_EMAIL_ID)
|
||||
val accountId = intent.getStringExtra(NotificationTapStore.EXTRA_ACCOUNT_ID)
|
||||
val jmapAccountId = intent.getStringExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID)
|
||||
|
||||
// Dismiss the notification immediately from the tray
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (notificationId != null) {
|
||||
manager.cancel(notificationId, notificationId.hashCode())
|
||||
}
|
||||
|
||||
if (emailId.isNullOrBlank() || action.isNullOrBlank() || accountId.isNullOrBlank()) {
|
||||
Log.w(TAG, "Missing parameters for notification action: action=$action, emailId=$emailId, accountId=$accountId")
|
||||
return
|
||||
}
|
||||
|
||||
// On Android 8+, a broadcast receiver in background cannot start a Service
|
||||
// (BackgroundServiceStartNotAllowedException). Instead, we use goAsync() and
|
||||
// HeadlessJs.withReadyReactContext to execute the headless JS task directly.
|
||||
val pendingResult = goAsync()
|
||||
val app = context.applicationContext as Application
|
||||
val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
// Safety timeout to ensure pendingResult.finish() is always called
|
||||
val timeoutRunnable = Runnable {
|
||||
Log.w(TAG, "Notification action task timed out or failed to initialize ($action, $emailId)")
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
mainHandler.postDelayed(timeoutRunnable, 20_000L)
|
||||
|
||||
try {
|
||||
HeadlessJs.withReadyReactContext(app) { reactContext ->
|
||||
if (!reactContext.hasActiveReactInstance()) {
|
||||
Log.w(TAG, "React context does not have active instance")
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
return@withReadyReactContext
|
||||
}
|
||||
|
||||
val tasks = HeadlessJsTaskContext.getInstance(reactContext)
|
||||
val taskBundle = Bundle().apply {
|
||||
putString("action", action)
|
||||
putString("emailId", emailId)
|
||||
putString("accountId", accountId)
|
||||
if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId)
|
||||
// Keep raw extras as well
|
||||
putString(BulwarkFcmModule.EXTRA_ACTION, action)
|
||||
putString(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
putString(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
if (jmapAccountId != null) putString(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
|
||||
}
|
||||
|
||||
val config = HeadlessJsTaskConfig(
|
||||
"BulwarkNotificationAction",
|
||||
Arguments.fromBundle(taskBundle),
|
||||
15_000L,
|
||||
true // allowExecutionInForeground
|
||||
)
|
||||
|
||||
val listener = object : HeadlessJsTaskEventListener {
|
||||
override fun onHeadlessJsTaskStart(taskId: Int) = Unit
|
||||
override fun onHeadlessJsTaskFinish(taskId: Int) {
|
||||
Log.i(TAG, "Notification action task $taskId finished for action $action")
|
||||
tasks.removeTaskEventListener(this)
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
tasks.addTaskEventListener(listener)
|
||||
|
||||
HeadlessJs.startTask(
|
||||
tasks,
|
||||
config,
|
||||
onStarted = { taskId ->
|
||||
Log.i(TAG, "Started notification action task $taskId for action $action ($emailId)")
|
||||
},
|
||||
onRefused = { e ->
|
||||
Log.w(TAG, "Notification action task refused: ${e.message}")
|
||||
tasks.removeTaskEventListener(listener)
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to dispatch headless task from action receiver", e)
|
||||
mainHandler.removeCallbacks(timeoutRunnable)
|
||||
try {
|
||||
pendingResult.finish()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BulwarkNotifAction"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.app.job.JobInfo
|
||||
import android.app.job.JobParameters
|
||||
import android.app.job.JobScheduler
|
||||
import android.app.job.JobService
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PersistableBundle
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.facebook.react.bridge.ReactContext
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Runs the BulwarkPushTask headless JS task from a JobScheduler job.
|
||||
*
|
||||
* A push normally starts BulwarkPushTaskService, but Android 8+ only lets a
|
||||
* background app start a service while it is temporarily allowlisted, which a
|
||||
* high-priority FCM message does. FCM delivers some messages at normal
|
||||
* priority anyway (it downgrades apps whose high-priority pushes rarely end in
|
||||
* a notification), and a UnifiedPush distributor may not raise the app at all.
|
||||
* The service start then throws, so the push is handed to this job instead:
|
||||
* expedited on Android 12+, where it runs right away while the app has
|
||||
* expedited-job quota and as a regular job after that, and a regular job on
|
||||
* Android 8-11. It needs no new dependency and ends in the same notification
|
||||
* as the service path.
|
||||
*/
|
||||
class BulwarkPushJobService : JobService(), HeadlessJsTaskEventListener {
|
||||
private class RunningTask(val params: JobParameters, val startedAt: Long)
|
||||
|
||||
private val running = ConcurrentHashMap<Int, RunningTask>()
|
||||
private var taskContext: HeadlessJsTaskContext? = null
|
||||
|
||||
override fun onStartJob(params: JobParameters): Boolean {
|
||||
val config = BulwarkPushTaskService.taskConfig(Bundle(params.extras))
|
||||
// JobService callbacks run on the main thread, where HeadlessJs wants to be called.
|
||||
HeadlessJs.withReadyReactContext(application) { startTask(it, config, params) }
|
||||
return true
|
||||
}
|
||||
|
||||
// The JS task has its own 30 s timeout and finishes on its own; nothing to
|
||||
// reschedule if the system stops the job first.
|
||||
override fun onStopJob(params: JobParameters): Boolean {
|
||||
running.values.removeIf { it.params.jobId == params.jobId }
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
taskContext?.removeTaskEventListener(this)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onHeadlessJsTaskStart(taskId: Int) = Unit
|
||||
|
||||
override fun onHeadlessJsTaskFinish(taskId: Int) {
|
||||
val task = running.remove(taskId) ?: return
|
||||
val ms = SystemClock.elapsedRealtime() - task.startedAt
|
||||
Log.i(TAG, "Push task $taskId from job ${task.params.jobId} finished after $ms ms")
|
||||
jobFinished(task.params, false)
|
||||
}
|
||||
|
||||
private fun startTask(context: ReactContext, config: HeadlessJsTaskConfig, params: JobParameters) {
|
||||
val tasks = HeadlessJsTaskContext.getInstance(context)
|
||||
if (taskContext !== tasks) {
|
||||
taskContext?.removeTaskEventListener(this)
|
||||
tasks.addTaskEventListener(this)
|
||||
taskContext = tasks
|
||||
}
|
||||
HeadlessJs.startTask(
|
||||
tasks,
|
||||
config,
|
||||
onStarted = { taskId -> running[taskId] = RunningTask(params, SystemClock.elapsedRealtime()) },
|
||||
onRefused = { e ->
|
||||
// The app came to the foreground since the push arrived and
|
||||
// syncs on its own there.
|
||||
Log.i(TAG, "Push job ${params.jobId} skipped: ${e.message}")
|
||||
jobFinished(params, false)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BulwarkPush"
|
||||
|
||||
// A block of job ids for push jobs, one per push that is waiting or
|
||||
// running. A pending job with the same id would be replaced (and a
|
||||
// running one stopped), so each push takes a free id.
|
||||
private const val JOB_ID_BASE = 0x42554c00
|
||||
private const val JOB_ID_COUNT = 32
|
||||
|
||||
/** Schedules the push task as a job. Returns false if no job could be scheduled. */
|
||||
fun schedule(context: Context, data: Map<String, String>): Boolean = try {
|
||||
val scheduler = context.getSystemService(JobScheduler::class.java)
|
||||
val busy = scheduler.allPendingJobs.mapTo(HashSet()) { it.id }
|
||||
val jobId = (JOB_ID_BASE until JOB_ID_BASE + JOB_ID_COUNT).firstOrNull { it !in busy }
|
||||
if (jobId == null) {
|
||||
// Every slot holds a push that has not finished yet; any of
|
||||
// them fetches the same new mail.
|
||||
Log.w(TAG, "Push not scheduled: $JOB_ID_COUNT push jobs already pending")
|
||||
false
|
||||
} else {
|
||||
val extras = PersistableBundle().apply {
|
||||
for ((key, value) in data) putString(key, value)
|
||||
}
|
||||
val job = JobInfo.Builder(jobId, ComponentName(context, BulwarkPushJobService::class.java))
|
||||
.setExtras(extras)
|
||||
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
|
||||
.apply { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) setExpedited(true) }
|
||||
.build()
|
||||
val scheduled = scheduler.schedule(job) == JobScheduler.RESULT_SUCCESS
|
||||
Log.i(TAG, "Push job $jobId scheduled: $scheduled")
|
||||
scheduled
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Push job could not be scheduled", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,32 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
import com.facebook.react.HeadlessJsTaskService
|
||||
|
||||
class BulwarkPushTaskService : HeadlessJsTaskService() {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
|
||||
super.onStartCommand(intent, flags, startId).also {
|
||||
// startTask has taken React Native's wake lock by now.
|
||||
BulwarkMessagingService.releaseStartWakeLock()
|
||||
}
|
||||
|
||||
override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig? {
|
||||
val extras = intent?.extras ?: return null
|
||||
return HeadlessJsTaskConfig(
|
||||
"BulwarkPushTask",
|
||||
Arguments.fromBundle(extras),
|
||||
30000L,
|
||||
false,
|
||||
)
|
||||
return taskConfig(extras)
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Shared with BulwarkPushJobService, which runs the same task when
|
||||
// this service may not be started.
|
||||
fun taskConfig(extras: Bundle): HeadlessJsTaskConfig =
|
||||
HeadlessJsTaskConfig(
|
||||
"BulwarkPushTask",
|
||||
Arguments.fromBundle(extras),
|
||||
30000L,
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
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 org.unifiedpush.android.connector.UnifiedPush
|
||||
|
||||
// JS-facing surface for UnifiedPush distributor management. Events from the
|
||||
// distributor (endpoint changes, incoming messages) arrive through
|
||||
// BulwarkUnifiedPushService, which emits them on the shared device-event
|
||||
// channel.
|
||||
class BulwarkUnifiedPushModule(reactContext: ReactApplicationContext)
|
||||
: ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
override fun getName(): String = "BulwarkUnifiedPush"
|
||||
|
||||
@ReactMethod
|
||||
fun getDistributors(promise: Promise) {
|
||||
try {
|
||||
val array = Arguments.createArray()
|
||||
for (d in UnifiedPush.getDistributors(reactApplicationContext)) array.pushString(d)
|
||||
promise.resolve(array)
|
||||
} catch (err: Exception) {
|
||||
promise.reject("up_distributors_failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun getSavedDistributor(promise: Promise) {
|
||||
try {
|
||||
promise.resolve(UnifiedPush.getSavedDistributor(reactApplicationContext))
|
||||
} catch (err: Exception) {
|
||||
promise.reject("up_distributor_failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun getAckDistributor(promise: Promise) {
|
||||
try {
|
||||
promise.resolve(UnifiedPush.getAckDistributor(reactApplicationContext))
|
||||
} catch (err: Exception) {
|
||||
promise.reject("up_distributor_failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun saveDistributor(distributor: String, promise: Promise) {
|
||||
try {
|
||||
UnifiedPush.saveDistributor(reactApplicationContext, distributor)
|
||||
promise.resolve(null)
|
||||
} catch (err: Exception) {
|
||||
promise.reject("up_save_distributor_failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Asks the saved distributor for an endpoint. The endpoint itself arrives
|
||||
// asynchronously via BulwarkUnifiedPushService.onNewEndpoint (event
|
||||
// `up:newEndpoint`); failures via `up:registrationFailed`. `vapid` is the
|
||||
// relay's VAPID public key - optional, some distributors use it to
|
||||
// restrict who may push.
|
||||
@ReactMethod
|
||||
fun register(vapid: String?, promise: Promise) {
|
||||
try {
|
||||
UnifiedPush.register(
|
||||
reactApplicationContext,
|
||||
messageForDistributor = null,
|
||||
vapid = vapid?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
promise.resolve(null)
|
||||
} catch (err: Exception) {
|
||||
promise.reject("up_register_failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun unregister(promise: Promise) {
|
||||
try {
|
||||
UnifiedPush.unregister(reactApplicationContext)
|
||||
BulwarkUnifiedPushService.clearEndpoint(reactApplicationContext)
|
||||
promise.resolve(null)
|
||||
} catch (err: Exception) {
|
||||
promise.reject("up_unregister_failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun getEndpoint(promise: Promise) {
|
||||
val stored = BulwarkUnifiedPushService.readEndpoint(reactApplicationContext)
|
||||
if (stored == null) {
|
||||
promise.resolve(null)
|
||||
return
|
||||
}
|
||||
val (url, p256dh, auth) = stored
|
||||
val map = Arguments.createMap().apply {
|
||||
putString("url", url)
|
||||
putString("p256dh", p256dh)
|
||||
putString("auth", auth)
|
||||
}
|
||||
promise.resolve(map)
|
||||
}
|
||||
|
||||
// NativeEventEmitter required no-ops.
|
||||
@ReactMethod
|
||||
fun addListener(eventName: String) {}
|
||||
|
||||
@ReactMethod
|
||||
fun removeListeners(count: Int) {}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Context
|
||||
import com.anonymous.bulwarkmobile.sync.DeviceSyncPushRouter
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.unifiedpush.android.connector.FailedReason
|
||||
import org.unifiedpush.android.connector.PushService
|
||||
import org.unifiedpush.android.connector.data.PushEndpoint
|
||||
import org.unifiedpush.android.connector.data.PushMessage
|
||||
|
||||
// Receives UnifiedPush events from the user's distributor app (ntfy, ...).
|
||||
// The relay sends the same content-blind ForwardPayload it sends over FCM -
|
||||
// as encrypted Web Push when the connector provided RFC 8291 keys (the
|
||||
// connector decrypts before this service sees it) - so an incoming message is
|
||||
// dispatched exactly like an FCM data message: headless task when the app is
|
||||
// backgrounded, `fcm:message` device event for the live JS instance.
|
||||
class BulwarkUnifiedPushService : PushService() {
|
||||
|
||||
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
|
||||
saveEndpoint(this, endpoint)
|
||||
val params = Arguments.createMap().apply {
|
||||
putString("url", endpoint.url)
|
||||
putString("p256dh", endpoint.pubKeySet?.pubKey)
|
||||
putString("auth", endpoint.pubKeySet?.auth)
|
||||
}
|
||||
BulwarkFcmModule.emit("up:newEndpoint", params)
|
||||
}
|
||||
|
||||
override fun onMessage(message: PushMessage, instance: String) {
|
||||
BulwarkMessagingService.ensureChannel(this)
|
||||
val data = parsePayload(message.content) ?: return
|
||||
|
||||
// Same routing as FCM: contact and calendar changes request device
|
||||
// syncs, and only mail starts the push task.
|
||||
val mail = DeviceSyncPushRouter.route(applicationContext, data)
|
||||
if (mail && !BulwarkMessagingService.isAppInForeground()) {
|
||||
BulwarkMessagingService.startHeadlessTask(applicationContext, data)
|
||||
}
|
||||
|
||||
// Same event name and shape as the FCM path so the foreground JS
|
||||
// handler (App.tsx addMessageListener) works transport-agnostically.
|
||||
val params = Arguments.createMap().apply {
|
||||
val dataMap = Arguments.createMap()
|
||||
for ((k, v) in data) dataMap.putString(k, v)
|
||||
putMap("data", dataMap)
|
||||
}
|
||||
BulwarkFcmModule.emit("fcm:message", params)
|
||||
}
|
||||
|
||||
override fun onUnregistered(instance: String) {
|
||||
clearEndpoint(this)
|
||||
BulwarkFcmModule.emit("up:unregistered", Arguments.createMap())
|
||||
}
|
||||
|
||||
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
|
||||
val params = Arguments.createMap().apply { putString("reason", reason.name) }
|
||||
BulwarkFcmModule.emit("up:registrationFailed", params)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFS = "bulwark_unifiedpush"
|
||||
private const val KEY_URL = "endpointUrl"
|
||||
private const val KEY_P256DH = "endpointP256dh"
|
||||
private const val KEY_AUTH = "endpointAuth"
|
||||
|
||||
// The endpoint arrives asynchronously (possibly while no JS instance
|
||||
// is alive), so persist it for BulwarkUnifiedPushModule.getEndpoint.
|
||||
fun saveEndpoint(context: Context, endpoint: PushEndpoint) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putString(KEY_URL, endpoint.url)
|
||||
.putString(KEY_P256DH, endpoint.pubKeySet?.pubKey)
|
||||
.putString(KEY_AUTH, endpoint.pubKeySet?.auth)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun readEndpoint(context: Context): Triple<String, String?, String?>? {
|
||||
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
val url = prefs.getString(KEY_URL, null) ?: return null
|
||||
return Triple(url, prefs.getString(KEY_P256DH, null), prefs.getString(KEY_AUTH, null))
|
||||
}
|
||||
|
||||
fun clearEndpoint(context: Context) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||
}
|
||||
|
||||
// The relay's ForwardPayload JSON, flattened to the string map the
|
||||
// FCM data payload uses (repos/relay/src/fcm.ts) so the headless task
|
||||
// and foreground handler can stay transport-blind.
|
||||
fun parsePayload(content: ByteArray): Map<String, String>? = try {
|
||||
val json = JSONObject(String(content, Charsets.UTF_8))
|
||||
buildMap {
|
||||
json.optString("kind").takeIf { it.isNotEmpty() }?.let { put("kind", it) }
|
||||
json.optString("accountLabel").takeIf { it.isNotEmpty() }?.let { put("accountLabel", it) }
|
||||
json.optString("accountId").takeIf { it.isNotEmpty() }?.let { put("accountId", it) }
|
||||
put("emailIds", (json.optJSONArray("emailIds") ?: JSONArray()).toString())
|
||||
put("changed", (json.optJSONObject("changed") ?: JSONObject()).toString())
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Intent
|
||||
import com.facebook.react.HeadlessJsTaskService
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
|
||||
/**
|
||||
* Runs the widgets' refresh (BulwarkWidgetRefresh, index.ts) as a headless
|
||||
* task when the app goes to the background. Run from the app itself the
|
||||
* refresh stalled: React Native pauses JS timers while the activity is in
|
||||
* the background, and fetch resolves every response through a timer, so the
|
||||
* first request went out and its answer was never read. A running headless
|
||||
* task keeps timers going, and the service keeps the process from being
|
||||
* frozen before the widgets are redrawn.
|
||||
*/
|
||||
class BulwarkWidgetRefreshService : HeadlessJsTaskService() {
|
||||
override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig =
|
||||
HeadlessJsTaskConfig("BulwarkWidgetRefresh", Arguments.createMap(), 30000L, true)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Intent
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
|
||||
/** JS side: src/widgets/sync.ts. */
|
||||
class BulwarkWidgetsModule(reactContext: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
override fun getName(): String = "BulwarkWidgets"
|
||||
|
||||
/** Starts BulwarkWidgetRefreshService; resolves false when Android refuses the start. */
|
||||
@ReactMethod
|
||||
fun refreshInBackground(promise: Promise) {
|
||||
try {
|
||||
reactApplicationContext.startService(Intent(reactApplicationContext, BulwarkWidgetRefreshService::class.java))
|
||||
promise.resolve(true)
|
||||
} catch (e: IllegalStateException) {
|
||||
// Background start limits (the app left the foreground too long ago).
|
||||
promise.resolve(false)
|
||||
} catch (e: SecurityException) {
|
||||
promise.resolve(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.annotation.MainThread
|
||||
import com.facebook.react.ReactApplication
|
||||
import com.facebook.react.ReactInstanceEventListener
|
||||
import com.facebook.react.bridge.ReactContext
|
||||
import com.facebook.react.bridge.UiThreadUtil
|
||||
import com.facebook.react.interfaces.TaskInterface
|
||||
import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Running a headless JS task without HeadlessJsTaskService: starting React
|
||||
* Native when the process was woken without any UI, and starting the task on
|
||||
* its context. Shared by the push job (BulwarkPushJobService) and the device
|
||||
* sync adapters.
|
||||
*/
|
||||
object HeadlessJs {
|
||||
private const val TAG = "HeadlessJs"
|
||||
|
||||
/** No caller waits longer for React Native: JobScheduler stops sync and push jobs after 10 minutes. */
|
||||
private const val MAX_WAIT_MS = 600_000L
|
||||
private const val WATCH_MS = 1_000L
|
||||
|
||||
/**
|
||||
* Calls [onReady] once with a React context whose JS instance is up,
|
||||
* starting React Native if the process was woken for this work.
|
||||
*
|
||||
* A context exists before its instance is ready, and a task started on
|
||||
* it then never reaches JS (HeadlessJsTaskContext only logs "CatalystInstance
|
||||
* not available" and waits for the timeout), which happened when several
|
||||
* pushes arrived while the app was starting. So the listener goes in
|
||||
* first and the ready check after it; whichever sees the ready instance
|
||||
* first runs the task.
|
||||
*
|
||||
* The wait ends once, and its listener goes with it: with a ready context,
|
||||
* or without one when React fails to start (the start task faults) or
|
||||
* after [MAX_WAIT_MS], when no caller can still be waiting. A context that
|
||||
* comes up later runs nothing of it, so waits given up on don't pile up on
|
||||
* the host with everything their [onReady] holds. (A caller that gives up
|
||||
* sooner, like the sync adapter at its deadline, checks that in [onReady].)
|
||||
*
|
||||
* Call it on the main thread; [onReady] runs there too. MainApplication
|
||||
* creates the ReactHost on first use without a lock (ExpoReactHostFactory),
|
||||
* so a background caller racing MainActivity could create a second one,
|
||||
* with a second JS runtime.
|
||||
*
|
||||
* Returns React Native's start task when it had to be started, so a caller
|
||||
* can notice a start that fails (a debug build with no Metro to load the
|
||||
* bundle from), or null when the context was already up.
|
||||
*/
|
||||
@MainThread
|
||||
fun withReadyReactContext(app: Application, onReady: (ReactContext) -> Unit): TaskInterface<Void>? {
|
||||
val reactApp = app as ReactApplication
|
||||
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
||||
val host = checkNotNull(reactApp.reactHost) { "ReactHost is not initialized in New Architecture" }
|
||||
val wait = ContextWait(onReady) { host.removeReactInstanceEventListener(it) }
|
||||
host.addReactInstanceEventListener(wait)
|
||||
val current = host.currentReactContext
|
||||
if (current != null && current.hasActiveReactInstance()) {
|
||||
wait.onReactContextInitialized(current)
|
||||
return null
|
||||
}
|
||||
return host.start().also { wait.watch(it) }
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
val manager = reactApp.reactNativeHost.reactInstanceManager
|
||||
val wait = ContextWait(onReady) { manager.removeReactInstanceEventListener(it) }
|
||||
manager.addReactInstanceEventListener(wait)
|
||||
val current = manager.currentReactContext
|
||||
if (current != null && current.hasActiveReactInstance()) {
|
||||
wait.onReactContextInitialized(current)
|
||||
return null
|
||||
}
|
||||
if (!manager.hasStartedCreatingInitialContext()) manager.createReactContextInBackground()
|
||||
wait.watch(null)
|
||||
return null
|
||||
}
|
||||
|
||||
/** One caller's wait for a ready context: a listener on the host that ends once and then removes itself. */
|
||||
private class ContextWait(
|
||||
private val onReady: (ReactContext) -> Unit,
|
||||
private val removeListener: (ReactInstanceEventListener) -> Unit,
|
||||
) : ReactInstanceEventListener {
|
||||
private val ended = AtomicBoolean(false)
|
||||
|
||||
override fun onReactContextInitialized(context: ReactContext) {
|
||||
if (end()) onReady(context)
|
||||
}
|
||||
|
||||
/** Ends the wait without a context once [boot] faulted or [MAX_WAIT_MS] passed; checked on the main thread. */
|
||||
fun watch(boot: TaskInterface<Void>?) {
|
||||
val main = Handler(Looper.getMainLooper())
|
||||
val giveUpAt = SystemClock.elapsedRealtime() + MAX_WAIT_MS
|
||||
main.postDelayed(object : Runnable {
|
||||
override fun run() {
|
||||
if (ended.get()) return
|
||||
val faulted = boot?.isFaulted() == true
|
||||
if (!faulted && SystemClock.elapsedRealtime() < giveUpAt) {
|
||||
main.postDelayed(this, WATCH_MS)
|
||||
} else if (end()) {
|
||||
val why = if (faulted) "React Native did not start" else "no React context after $MAX_WAIT_MS ms"
|
||||
Log.w(TAG, "$why; no longer waiting")
|
||||
}
|
||||
}
|
||||
}, WATCH_MS)
|
||||
}
|
||||
|
||||
private fun end(): Boolean {
|
||||
if (!ended.compareAndSet(false, true)) return false
|
||||
removeListener(this)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts [config] on the UI thread, where HeadlessJsTaskContext wants it.
|
||||
* [onStarted] gets the task id in that same UI-thread turn, so before any
|
||||
* finish callback (HeadlessJsTaskContext posts those to the UI thread).
|
||||
* [onRefused] gets the refusal when the context is resumed and [config]
|
||||
* isn't allowed in the foreground.
|
||||
*/
|
||||
fun startTask(
|
||||
tasks: HeadlessJsTaskContext,
|
||||
config: HeadlessJsTaskConfig,
|
||||
onStarted: (taskId: Int) -> Unit,
|
||||
onRefused: (IllegalStateException) -> Unit,
|
||||
) {
|
||||
UiThreadUtil.runOnUiThread {
|
||||
val taskId = try {
|
||||
tasks.startTask(config)
|
||||
} catch (e: IllegalStateException) {
|
||||
onRefused(e)
|
||||
return@runOnUiThread
|
||||
}
|
||||
onStarted(taskId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,13 +17,19 @@ class MainActivity : ReactActivity() {
|
||||
// coloring the background, status bar, and navigation bar.
|
||||
// This is required for expo-splash-screen.
|
||||
setTheme(R.style.AppTheme);
|
||||
ShareIntentStore.rewriteSendToAsView(intent)
|
||||
super.onCreate(null)
|
||||
NotificationTapStore.captureFromIntent(intent)
|
||||
ShareIntentStore.captureFromIntent(intent, contentResolver)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
ShareIntentStore.rewriteSendToAsView(intent)
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
ShareIntentStore.captureFromIntent(intent, contentResolver)?.let { share ->
|
||||
BulwarkFcmModule.emit("app:share", share.toMap())
|
||||
}
|
||||
val payload = NotificationTapStore.captureFromIntent(intent) ?: return
|
||||
BulwarkFcmModule.emit("fcm:notificationTap", payload.toMap())
|
||||
}
|
||||
|
||||
@@ -17,17 +17,42 @@ object NotificationTapStore {
|
||||
return value
|
||||
}
|
||||
|
||||
// The activity is exported (required by the LAUNCHER intent filter), so
|
||||
// any third-party app on the device can craft a starting intent that
|
||||
// includes these extras. Reject anything that doesn't look like the IDs
|
||||
// our notification builder writes — preventing other apps from
|
||||
// navigating us to a forged email/thread.
|
||||
private val ID_PATTERN = Regex("^[A-Za-z0-9_-]{1,128}$")
|
||||
// accountId is `username@host` (see generateAccountId), so it needs `.`,
|
||||
// `@`, `+` etc. — cap length and restrict charset to what that helper can
|
||||
// emit. accountId is optional: notifications created before this field
|
||||
// existed still navigate (using the active account as a fallback).
|
||||
private val ACCOUNT_ID_PATTERN = Regex("^[A-Za-z0-9._+@-]{1,256}$")
|
||||
|
||||
fun captureFromIntent(intent: Intent?): TapPayload? {
|
||||
val extras = intent?.extras ?: return null
|
||||
val emailId = extras.getString(EXTRA_EMAIL_ID) ?: return null
|
||||
val threadId = extras.getString(EXTRA_THREAD_ID) ?: return null
|
||||
val subject = extras.getString(EXTRA_SUBJECT)
|
||||
val payload = TapPayload(emailId, threadId, subject)
|
||||
val emailId = extras.getString(EXTRA_EMAIL_ID)?.takeIf { ID_PATTERN.matches(it) }
|
||||
?: return null
|
||||
val threadId = extras.getString(EXTRA_THREAD_ID)?.takeIf { ID_PATTERN.matches(it) }
|
||||
?: return null
|
||||
// Subject is human-readable text and may legitimately contain anything;
|
||||
// cap its length so a hostile launcher can't ship a 1MB string into
|
||||
// the navigation payload.
|
||||
val subject = extras.getString(EXTRA_SUBJECT)?.take(512)
|
||||
val accountId = extras.getString(EXTRA_ACCOUNT_ID)?.takeIf { ACCOUNT_ID_PATTERN.matches(it) }
|
||||
// The JMAP account a group/shared mailbox's message lives in - a JMAP
|
||||
// id like the email id. Optional: older notifications don't carry it.
|
||||
val jmapAccountId = extras.getString(EXTRA_JMAP_ACCOUNT_ID)?.takeIf { ID_PATTERN.matches(it) }
|
||||
val action = extras.getString(EXTRA_ACTION)?.takeIf { it == "reply" }
|
||||
val payload = TapPayload(emailId, threadId, subject, accountId, jmapAccountId, action)
|
||||
pending = payload
|
||||
// Clear so a subsequent activity lifecycle event doesn't replay this.
|
||||
extras.remove(EXTRA_EMAIL_ID)
|
||||
extras.remove(EXTRA_THREAD_ID)
|
||||
extras.remove(EXTRA_SUBJECT)
|
||||
extras.remove(EXTRA_ACCOUNT_ID)
|
||||
extras.remove(EXTRA_JMAP_ACCOUNT_ID)
|
||||
extras.remove(EXTRA_ACTION)
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -35,15 +60,24 @@ object NotificationTapStore {
|
||||
val emailId: String,
|
||||
val threadId: String,
|
||||
val subject: String?,
|
||||
val accountId: String?,
|
||||
val jmapAccountId: String?,
|
||||
val action: String? = null,
|
||||
) {
|
||||
fun toMap(): WritableMap = Arguments.createMap().apply {
|
||||
putString("emailId", emailId)
|
||||
putString("threadId", threadId)
|
||||
if (subject != null) putString("subject", subject)
|
||||
if (accountId != null) putString("accountId", accountId)
|
||||
if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId)
|
||||
if (action != null) putString("action", action)
|
||||
}
|
||||
}
|
||||
|
||||
const val EXTRA_EMAIL_ID = "bulwark.notification.emailId"
|
||||
const val EXTRA_THREAD_ID = "bulwark.notification.threadId"
|
||||
const val EXTRA_SUBJECT = "bulwark.notification.subject"
|
||||
const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId"
|
||||
const val EXTRA_JMAP_ACCOUNT_ID = "bulwark.notification.jmapAccountId"
|
||||
const val EXTRA_ACTION = "bulwark.notification.action"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.OpenableColumns
|
||||
import android.webkit.MimeTypeMap
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.WritableMap
|
||||
|
||||
// Process-wide holder for the most recent ACTION_SEND / ACTION_SEND_MULTIPLE
|
||||
// intent (the system share sheet). MainActivity fills this on onCreate /
|
||||
// onNewIntent; JS drains it via BulwarkFcm.getInitialShare or reacts to the
|
||||
// live "app:share" event and opens the composer. `SENDTO mailto:` intents are
|
||||
// rewritten into `VIEW mailto:` links instead (see rewriteSendToAsView).
|
||||
object ShareIntentStore {
|
||||
@Volatile
|
||||
private var pending: SharePayload? = null
|
||||
|
||||
fun consume(): SharePayload? {
|
||||
val value = pending
|
||||
pending = null
|
||||
return value
|
||||
}
|
||||
|
||||
fun captureFromIntent(intent: Intent?, resolver: ContentResolver): SharePayload? {
|
||||
val action = intent?.action ?: return null
|
||||
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return null
|
||||
|
||||
val text = intent.getStringExtra(Intent.EXTRA_TEXT)?.take(MAX_TEXT)
|
||||
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)?.take(512)
|
||||
val uris = mutableListOf<Uri>()
|
||||
if (action == Intent.ACTION_SEND) {
|
||||
streamExtra(intent)?.let { uris.add(it) }
|
||||
} else {
|
||||
streamExtras(intent)?.let { uris.addAll(it) }
|
||||
}
|
||||
val type = intent.type
|
||||
|
||||
if (text.isNullOrBlank() && uris.isEmpty()) return null
|
||||
val files = uris.mapIndexed { i, uri -> describe(resolver, uri, type, i) }
|
||||
val payload = SharePayload(
|
||||
text = text,
|
||||
subject = subject,
|
||||
uris = uris.map { it.toString() },
|
||||
mimeTypes = files.map { it.mimeType },
|
||||
names = files.map { it.name },
|
||||
sizes = files.map { it.size },
|
||||
)
|
||||
pending = payload
|
||||
// Clear so a later lifecycle event doesn't replay this share.
|
||||
intent.removeExtra(Intent.EXTRA_TEXT)
|
||||
intent.removeExtra(Intent.EXTRA_SUBJECT)
|
||||
intent.removeExtra(Intent.EXTRA_STREAM)
|
||||
intent.action = Intent.ACTION_MAIN
|
||||
return payload
|
||||
}
|
||||
|
||||
// `SENDTO mailto:` is how most apps start an email, but React Native's
|
||||
// Linking only forwards VIEW intents, so the app opened on the inbox.
|
||||
// Turn it into the equivalent `VIEW mailto:` link, folding the recipient,
|
||||
// subject and text extras into the URI, so it reaches JS like any other
|
||||
// mailto link: Linking.getInitialURL on a cold start, the "url" event
|
||||
// while running. Must run before the intent is handed to React Native.
|
||||
fun rewriteSendToAsView(intent: Intent?) {
|
||||
val action = intent?.action ?: return
|
||||
if (action != Intent.ACTION_SENDTO) return
|
||||
val data = intent.data ?: return
|
||||
if (!"mailto".equals(data.scheme, ignoreCase = true)) return
|
||||
|
||||
val params = mutableListOf<String>()
|
||||
addressExtra(intent, Intent.EXTRA_EMAIL).forEach { params.add("to=${Uri.encode(it)}") }
|
||||
addressExtra(intent, Intent.EXTRA_CC).forEach { params.add("cc=${Uri.encode(it)}") }
|
||||
addressExtra(intent, Intent.EXTRA_BCC).forEach { params.add("bcc=${Uri.encode(it)}") }
|
||||
intent.getCharSequenceExtra(Intent.EXTRA_SUBJECT)?.let {
|
||||
params.add("subject=${Uri.encode(it.toString().take(512))}")
|
||||
}
|
||||
intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.let {
|
||||
params.add("body=${Uri.encode(it.toString().take(MAX_TEXT))}")
|
||||
}
|
||||
|
||||
val link = data.toString()
|
||||
val separator = if (link.contains('?')) "&" else "?"
|
||||
intent.action = Intent.ACTION_VIEW
|
||||
intent.data = if (params.isEmpty()) data else Uri.parse(link + separator + params.joinToString("&"))
|
||||
}
|
||||
|
||||
// EXTRA_EMAIL/CC/BCC are documented as String[], but some apps put a
|
||||
// single String.
|
||||
private fun addressExtra(intent: Intent, key: String): List<String> =
|
||||
intent.getStringArrayExtra(key)?.filterNotNull() ?: listOfNotNull(intent.getStringExtra(key))
|
||||
|
||||
private class SharedFile(val name: String, val mimeType: String, val size: Double)
|
||||
|
||||
// Ask the provider for the file's real name, type and size: a MediaStore
|
||||
// URI ends in a bare row id ("1000000027") and SEND_MULTIPLE only carries
|
||||
// a wildcard type such as "image/*". Size is -1 when unknown.
|
||||
private fun describe(resolver: ContentResolver, uri: Uri, intentType: String?, index: Int): SharedFile {
|
||||
var displayName: String? = null
|
||||
var size = -1.0
|
||||
try {
|
||||
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val nameColumn = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (nameColumn >= 0 && !cursor.isNull(nameColumn)) displayName = cursor.getString(nameColumn)
|
||||
val sizeColumn = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
if (sizeColumn >= 0 && !cursor.isNull(sizeColumn)) size = cursor.getLong(sizeColumn).toDouble()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Provider without OpenableColumns: fall back to the URI below.
|
||||
}
|
||||
val resolvedType = try {
|
||||
resolver.getType(uri)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val mimeType = resolvedType ?: intentType?.takeUnless { it.contains('*') } ?: ""
|
||||
val name = displayName?.takeIf { it.isNotBlank() } ?: fallbackName(uri, mimeType, index)
|
||||
return SharedFile(name, mimeType, size)
|
||||
}
|
||||
|
||||
// Last path segment plus an extension for the MIME type, so the
|
||||
// recipient's mail client can still open the attachment.
|
||||
private fun fallbackName(uri: Uri, mimeType: String, index: Int): String {
|
||||
val base = uri.lastPathSegment?.substringAfterLast('/')?.takeIf { it.isNotBlank() }
|
||||
?: "shared-${index + 1}"
|
||||
if (base.contains('.')) return base
|
||||
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)
|
||||
return if (extension.isNullOrEmpty()) base else "$base.$extension"
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun streamExtra(intent: Intent): Uri? =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
} else {
|
||||
intent.getParcelableExtra(Intent.EXTRA_STREAM)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun streamExtras(intent: Intent): List<Uri>? =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
} else {
|
||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
|
||||
}
|
||||
|
||||
data class SharePayload(
|
||||
val text: String?,
|
||||
val subject: String?,
|
||||
val uris: List<String>,
|
||||
val mimeTypes: List<String>,
|
||||
val names: List<String>,
|
||||
val sizes: List<Double>,
|
||||
) {
|
||||
fun toMap(): WritableMap = Arguments.createMap().apply {
|
||||
if (text != null) putString("text", text)
|
||||
if (subject != null) putString("subject", subject)
|
||||
putArray("uris", Arguments.fromList(uris))
|
||||
putArray("mimeTypes", Arguments.fromList(mimeTypes))
|
||||
putArray("names", Arguments.fromList(names))
|
||||
putArray("sizes", Arguments.fromList(sizes))
|
||||
}
|
||||
}
|
||||
|
||||
// 1 MiB of shared text is plenty; anything bigger is an attachment.
|
||||
private const val MAX_TEXT = 1 * 1024 * 1024
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.accounts.AbstractAccountAuthenticator
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountAuthenticatorResponse
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import com.anonymous.bulwarkmobile.MainActivity
|
||||
|
||||
/**
|
||||
* The authenticator of the "Bulwark" account type. The app holds the
|
||||
* credentials, not the account: accounts are only ever created by the app,
|
||||
* from Settings → Contacts or Calendar, for an account that is signed in.
|
||||
*/
|
||||
class BulwarkAuthenticator(private val context: Context) : AbstractAccountAuthenticator(context) {
|
||||
|
||||
/** Settings → Accounts → Add account opens the app's device sync settings instead. */
|
||||
override fun addAccount(
|
||||
response: AccountAuthenticatorResponse?,
|
||||
accountType: String?,
|
||||
authTokenType: String?,
|
||||
requiredFeatures: Array<out String>?,
|
||||
options: Bundle?,
|
||||
): Bundle {
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
.setAction(Intent.ACTION_VIEW)
|
||||
.setData(Uri.parse(SETTINGS_URI))
|
||||
return Bundle().apply { putParcelable(AccountManager.KEY_INTENT, intent) }
|
||||
}
|
||||
|
||||
/** Removal from Android Settings is allowed; the app notices and turns device sync off. */
|
||||
override fun getAccountRemovalAllowed(response: AccountAuthenticatorResponse?, account: Account?): Bundle =
|
||||
Bundle().apply { putBoolean(AccountManager.KEY_BOOLEAN_RESULT, true) }
|
||||
|
||||
override fun getAuthToken(
|
||||
response: AccountAuthenticatorResponse?,
|
||||
account: Account?,
|
||||
authTokenType: String?,
|
||||
options: Bundle?,
|
||||
): Bundle = unsupported()
|
||||
|
||||
override fun confirmCredentials(response: AccountAuthenticatorResponse?, account: Account?, options: Bundle?): Bundle =
|
||||
unsupported()
|
||||
|
||||
override fun updateCredentials(
|
||||
response: AccountAuthenticatorResponse?,
|
||||
account: Account?,
|
||||
authTokenType: String?,
|
||||
options: Bundle?,
|
||||
): Bundle = unsupported()
|
||||
|
||||
override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?): Bundle = unsupported()
|
||||
|
||||
override fun getAuthTokenLabel(authTokenType: String?): String? = null
|
||||
|
||||
override fun hasFeatures(response: AccountAuthenticatorResponse?, account: Account?, features: Array<out String>?): Bundle =
|
||||
Bundle().apply { putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false) }
|
||||
|
||||
private fun unsupported() = Bundle().apply {
|
||||
putInt(AccountManager.KEY_ERROR_CODE, AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION)
|
||||
putString(AccountManager.KEY_ERROR_MESSAGE, "Bulwark signs in from the app")
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Handled by the app's deep links: Settings → Contacts, with its "Sync to this device" section. */
|
||||
const val SETTINGS_URI = "bulwarkmobile://settings/contacts"
|
||||
}
|
||||
}
|
||||
|
||||
class BulwarkAuthenticatorService : Service() {
|
||||
private val authenticator by lazy { BulwarkAuthenticator(this) }
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? = authenticator.iBinder
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.accounts.OnAccountsUpdateListener
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ContentResolver
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
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 com.facebook.react.bridge.WritableMap
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.RejectedExecutionException
|
||||
|
||||
/**
|
||||
* `NativeModules.BulwarkDeviceSync`: Android accounts, sync settings, the run
|
||||
* handshake, provider I/O, push routes and the sync-problem notification of
|
||||
* device sync (#34). The contract is `DeviceSyncNativeModule` in
|
||||
* src/device-sync/types.ts; docs/device-sync.md explains it.
|
||||
*/
|
||||
class BulwarkDeviceSyncModule(reactContext: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
private val accountsListener = OnAccountsUpdateListener {
|
||||
emit(ACCOUNTS_CHANGED_EVENT, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider I/O runs here, one call at a time: a batch can hold the
|
||||
* provider for seconds (it sleeps at a contended yield point), which would
|
||||
* stall every other native module call on React's native-modules thread.
|
||||
*/
|
||||
private val io: ExecutorService = Executors.newSingleThreadExecutor { task ->
|
||||
Thread(task, "BulwarkDeviceSync-io").apply { isDaemon = true }
|
||||
}
|
||||
|
||||
init {
|
||||
synchronized(BulwarkDeviceSyncModule::class.java) { currentInstance = this }
|
||||
}
|
||||
|
||||
override fun getName(): String = NAME
|
||||
|
||||
override fun initialize() {
|
||||
super.initialize()
|
||||
val manager = AccountManager.get(reactApplicationContext)
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
manager.addOnAccountsUpdatedListener(accountsListener, handler, false, arrayOf(accountType()))
|
||||
} else {
|
||||
manager.addOnAccountsUpdatedListener(accountsListener, handler, false)
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "Account change listener not registered", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun invalidate() {
|
||||
try {
|
||||
AccountManager.get(reactApplicationContext).removeOnAccountsUpdatedListener(accountsListener)
|
||||
} catch (e: RuntimeException) {
|
||||
// Not registered.
|
||||
}
|
||||
io.shutdown()
|
||||
super.invalidate()
|
||||
}
|
||||
|
||||
private fun accountType() = DeviceSyncAccounts.accountType(reactApplicationContext)
|
||||
|
||||
// ── accounts ────────────────────────────────────────────────
|
||||
|
||||
@ReactMethod
|
||||
fun getInfo(promise: Promise) {
|
||||
promise.resolve(Arguments.createMap().apply {
|
||||
putString("accountType", accountType())
|
||||
putInt("sdkInt", Build.VERSION.SDK_INT)
|
||||
})
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun listAccounts(promise: Promise) = settle(promise) {
|
||||
Arguments.createArray().apply {
|
||||
for ((account, registryId) in DeviceSyncAccounts.list(reactApplicationContext)) {
|
||||
pushMap(Arguments.createMap().apply {
|
||||
putString("name", account.name)
|
||||
putString("registryId", registryId)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun ensureAccount(name: String, registryId: String, promise: Promise) = settle(promise) {
|
||||
DeviceSyncAccounts.ensure(reactApplicationContext, name, registryId)
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun removeAccount(name: String, promise: Promise) = settle(promise) {
|
||||
DeviceSyncAccounts.remove(reactApplicationContext, name)
|
||||
}
|
||||
|
||||
// ── sync settings ───────────────────────────────────────────
|
||||
|
||||
@ReactMethod
|
||||
fun getSyncSettings(name: String, promise: Promise) = settle(promise) {
|
||||
val account = DeviceSyncAccounts.account(reactApplicationContext, name)
|
||||
Arguments.createMap().apply {
|
||||
putBoolean("masterAutomatic", ContentResolver.getMasterSyncAutomatically())
|
||||
putMap("authorities", Arguments.createMap().apply {
|
||||
for (authority in DeviceSyncAccounts.AUTHORITIES) putMap(authority, authoritySettings(account, authority))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun authoritySettings(account: Account, authority: String): WritableMap = Arguments.createMap().apply {
|
||||
putInt("syncable", ContentResolver.getIsSyncable(account, authority))
|
||||
putBoolean("automatic", ContentResolver.getSyncAutomatically(account, authority))
|
||||
val periodic = ContentResolver.getPeriodicSyncs(account, authority).minOfOrNull { it.period } ?: 0L
|
||||
putDouble("periodicSeconds", periodic.toDouble())
|
||||
putBoolean("active", ContentResolver.isSyncActive(account, authority))
|
||||
putBoolean("pending", ContentResolver.isSyncPending(account, authority))
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun setSyncEnabled(name: String, authority: String, enabled: Boolean, promise: Promise) = settle(promise) {
|
||||
DeviceSyncAccounts.setSyncEnabled(reactApplicationContext, name, authority, enabled)
|
||||
null
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun setPeriodicSync(name: String, authority: String, seconds: Double, promise: Promise) = settle(promise) {
|
||||
DeviceSyncAccounts.setPeriodicSync(reactApplicationContext, name, authority, seconds.toLong())
|
||||
null
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun requestSync(name: String, authority: String, optionsJson: String, promise: Promise) = settle(promise) {
|
||||
val options = JSONObject(optionsJson)
|
||||
val flags = SyncRequestFlags(
|
||||
manual = options.optBoolean("manual"),
|
||||
expedited = options.optBoolean("expedited"),
|
||||
upload = options.optBoolean("upload"),
|
||||
overrideTooManyDeletions = options.optBoolean("overrideTooManyDeletions"),
|
||||
discardLocalDeletions = options.optBoolean("discardLocalDeletions"),
|
||||
)
|
||||
DeviceSyncAccounts.requestSync(reactApplicationContext, name, authority, flags)
|
||||
null
|
||||
}
|
||||
|
||||
/** The account's own sync screen where the platform has one, else the list of synced accounts. */
|
||||
@ReactMethod
|
||||
fun openAccountSettings(name: String, promise: Promise) = settle(promise) {
|
||||
val account = DeviceSyncAccounts.account(reactApplicationContext, name)
|
||||
val perAccount = Intent(ACTION_ACCOUNT_SYNC_SETTINGS)
|
||||
.putExtra(EXTRA_ACCOUNT, account)
|
||||
// Newer Settings apps pass only these arguments on to the account's
|
||||
// screen; without them it closes at once ("Account provided does
|
||||
// not exist: null", Android 12L), and no exception tells us so.
|
||||
.putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, Bundle().apply { putParcelable(EXTRA_ACCOUNT, account) })
|
||||
val overview = Intent(Settings.ACTION_SYNC_SETTINGS)
|
||||
.putExtra(Settings.EXTRA_ACCOUNT_TYPES, arrayOf(account.type))
|
||||
val activity = reactApplicationContext.currentActivity
|
||||
for (intent in listOf(perAccount, overview)) {
|
||||
try {
|
||||
if (activity != null) {
|
||||
activity.startActivity(intent)
|
||||
} else {
|
||||
reactApplicationContext.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
return@settle null
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
// Try the next one.
|
||||
}
|
||||
}
|
||||
throw IllegalStateException("No settings screen for sync accounts")
|
||||
}
|
||||
|
||||
// ── run handshake ───────────────────────────────────────────
|
||||
|
||||
@ReactMethod
|
||||
fun finishRun(runId: String, reportJson: String, promise: Promise) {
|
||||
val accepted = DeviceSyncRuns.finish(runId, reportJson)
|
||||
if (!accepted) Log.i(TAG, "Report for run $runId ignored: nothing waits for it")
|
||||
promise.resolve(accepted)
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun isRunCancelled(runId: String, promise: Promise) {
|
||||
promise.resolve(DeviceSyncRuns.isCancelled(runId))
|
||||
}
|
||||
|
||||
// ── provider I/O ────────────────────────────────────────────
|
||||
|
||||
/** `ProviderQuery` JSON in, `ProviderRows` JSON out; rejects with `scope`, `permission` or `provider`. */
|
||||
@ReactMethod
|
||||
fun query(name: String, authority: String, queryJson: String, promise: Promise) = onIo(promise) {
|
||||
ProviderIo(reactApplicationContext, name, authority).use { it.query(queryJson) }
|
||||
}
|
||||
|
||||
/** `ProviderOp[]` JSON in, `BatchResult` JSON out. Never rejects: a failed batch is a result. */
|
||||
@ReactMethod
|
||||
fun applyBatch(name: String, authority: String, opsJson: String, promise: Promise) {
|
||||
val failed = { e: Throwable -> ProviderResults.failed(ProviderIo.failureOf(e), e.message ?: e.javaClass.simpleName) }
|
||||
try {
|
||||
io.execute {
|
||||
val result = try {
|
||||
ProviderIo(reactApplicationContext, name, authority).use { it.applyBatch(opsJson) }
|
||||
} catch (e: Throwable) {
|
||||
failed(e)
|
||||
}
|
||||
promise.resolve(result)
|
||||
}
|
||||
} catch (e: RejectedExecutionException) {
|
||||
promise.resolve(failed(e))
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun readSyncState(name: String, authority: String, promise: Promise) = onIo(promise) {
|
||||
ProviderIo(reactApplicationContext, name, authority).use { it.readSyncState() }
|
||||
}
|
||||
|
||||
/** `PhotoData` JSON, or null when the raw contact has no photo; rejects with `scope` for another account's. */
|
||||
@ReactMethod
|
||||
fun readPhoto(name: String, rawContactId: Double, maxPx: Double, promise: Promise) = onIo(promise) {
|
||||
ProviderIo(reactApplicationContext, name, DeviceSyncAccounts.CONTACTS_AUTHORITY)
|
||||
.use { it.readPhoto(rawContactId.toLong(), maxPx.toInt()) }
|
||||
}
|
||||
|
||||
/** Settles [promise] with [block]'s result on the I/O thread; rejects with a `BatchFailure` code. */
|
||||
private fun onIo(promise: Promise, block: () -> Any?) {
|
||||
try {
|
||||
io.execute {
|
||||
try {
|
||||
promise.resolve(block())
|
||||
} catch (e: Throwable) {
|
||||
promise.reject(ProviderIo.failureOf(e).wire, e.message ?: e.javaClass.simpleName, e)
|
||||
}
|
||||
}
|
||||
} catch (e: RejectedExecutionException) {
|
||||
promise.reject("provider", "Device sync is shutting down", e)
|
||||
}
|
||||
}
|
||||
|
||||
// ── push routes and notifications ───────────────────────────
|
||||
|
||||
/** `{ jmapAccountId: [{ accountName, authorities }] }` for the push router (DeviceSyncPushRouter). */
|
||||
@ReactMethod
|
||||
fun setPushRoutes(routesJson: String, promise: Promise) = settle(promise) {
|
||||
DeviceSyncPushRouter.saveRoutes(reactApplicationContext, routesJson)
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts (or replaces) the account's sync-problem notification. [channelName]
|
||||
* names the notification channel in the app's language; null keeps English.
|
||||
*/
|
||||
@ReactMethod
|
||||
fun showSyncProblem(
|
||||
accountName: String,
|
||||
title: String,
|
||||
text: String,
|
||||
uri: String,
|
||||
channelName: String?,
|
||||
promise: Promise,
|
||||
) = settle(promise) {
|
||||
SyncProblemNotifier.show(reactApplicationContext, accountName, title, text, uri, channelName)
|
||||
null
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun clearSyncProblem(accountName: String, promise: Promise) = settle(promise) {
|
||||
SyncProblemNotifier.clear(reactApplicationContext, accountName)
|
||||
null
|
||||
}
|
||||
|
||||
// NativeEventEmitter bookkeeping.
|
||||
@ReactMethod
|
||||
fun addListener(eventName: String) {}
|
||||
|
||||
@ReactMethod
|
||||
fun removeListeners(count: Int) {}
|
||||
|
||||
private inline fun settle(promise: Promise, block: () -> Any?) {
|
||||
try {
|
||||
promise.resolve(block())
|
||||
} catch (e: AccountConflictException) {
|
||||
promise.reject("conflict", e.message, e)
|
||||
} catch (e: SecurityException) {
|
||||
promise.reject("permission", e.message, e)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
promise.reject("bad_args", e.message, e)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("failed", e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NAME = "BulwarkDeviceSync"
|
||||
private const val TAG = "BulwarkDeviceSync"
|
||||
const val ACCOUNTS_CHANGED_EVENT = "BulwarkDeviceSync:accountsChanged"
|
||||
|
||||
/** Settings' per-account sync screen; public on AOSP-based settings apps, but not in the SDK. */
|
||||
private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS"
|
||||
private const val EXTRA_ACCOUNT = "account"
|
||||
/** SettingsActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS: the arguments of the screen it shows. */
|
||||
private const val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args"
|
||||
|
||||
@Volatile private var currentInstance: BulwarkDeviceSyncModule? = null
|
||||
|
||||
/** Dropped when no React instance or no JS listener is there, like BulwarkFcmModule.emit. */
|
||||
fun emit(eventName: String, params: WritableMap?) {
|
||||
val module = currentInstance ?: return
|
||||
val ctx = module.reactApplicationContext
|
||||
if (!ctx.hasActiveReactInstance()) return
|
||||
ctx.getJSModule(RCTDeviceEventEmitter::class.java).emit(eventName, params)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import com.anonymous.bulwarkmobile.R
|
||||
|
||||
/**
|
||||
* The Android accounts of device sync (#34) and their sync settings. One
|
||||
* account per app account that has device sync on; see docs/device-sync.md.
|
||||
*
|
||||
* Android's own sync settings are the source of truth for on/off: the
|
||||
* Contacts and Calendar toggles in Settings → Accounts are
|
||||
* `getSyncAutomatically`, and the app reads them back instead of keeping a
|
||||
* copy.
|
||||
*/
|
||||
object DeviceSyncAccounts {
|
||||
const val CONTACTS_AUTHORITY: String = ContactsContract.AUTHORITY
|
||||
const val CALENDAR_AUTHORITY: String = CalendarContract.AUTHORITY
|
||||
val AUTHORITIES = listOf(CONTACTS_AUTHORITY, CALENDAR_AUTHORITY)
|
||||
|
||||
/** userData key holding the app account (`AccountEntry.id`) an Android account belongs to. */
|
||||
const val USER_DATA_REGISTRY_ID = "registryId"
|
||||
|
||||
/** `<applicationId>.account`, set in build.gradle so the package name is never hard-coded. */
|
||||
fun accountType(context: Context): String = context.getString(R.string.device_sync_account_type)
|
||||
|
||||
fun account(context: Context, name: String) = Account(name, accountType(context))
|
||||
|
||||
fun list(context: Context): List<Pair<Account, String?>> {
|
||||
val manager = AccountManager.get(context)
|
||||
return manager.getAccountsByType(accountType(context)).map { it to manager.getUserData(it, USER_DATA_REGISTRY_ID) }
|
||||
}
|
||||
|
||||
fun exists(context: Context, name: String): Boolean =
|
||||
AccountManager.get(context).getAccountsByType(accountType(context)).any { it.name == name }
|
||||
|
||||
/**
|
||||
* Adds the account when it is missing. Both authorities start syncable
|
||||
* with automatic sync off; the app turns on the ones the user picked.
|
||||
* Returns true when the account was created, false when it exists for
|
||||
* [registryId] already. An account that belongs to another registry id
|
||||
* keeps it ([AccountConflictException]): its rows and SyncState were
|
||||
* written for that app account.
|
||||
*/
|
||||
fun ensure(context: Context, name: String, registryId: String): Boolean {
|
||||
val manager = AccountManager.get(context)
|
||||
val account = account(context, name)
|
||||
val exists = manager.getAccountsByType(account.type).any { it.name == name }
|
||||
val current = if (exists) manager.getUserData(account, USER_DATA_REGISTRY_ID) else null
|
||||
when (ensureAction(exists, current, registryId)) {
|
||||
EnsureAction.KEEP -> return false
|
||||
EnsureAction.ADOPT -> {
|
||||
manager.setUserData(account, USER_DATA_REGISTRY_ID, registryId)
|
||||
return false
|
||||
}
|
||||
EnsureAction.CONFLICT -> throw AccountConflictException(
|
||||
"The Android account $name belongs to another app account ($current)",
|
||||
)
|
||||
EnsureAction.CREATE -> Unit
|
||||
}
|
||||
val userData = Bundle().apply { putString(USER_DATA_REGISTRY_ID, registryId) }
|
||||
check(manager.addAccountExplicitly(account, null, userData)) { "Android refused to add the account" }
|
||||
for (authority in AUTHORITIES) {
|
||||
ContentResolver.setIsSyncable(account, authority, 1)
|
||||
ContentResolver.setSyncAutomatically(account, authority, false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
enum class EnsureAction { CREATE, KEEP, ADOPT, CONFLICT }
|
||||
|
||||
/** An existing account without a registry id (never completed) is adopted; one with another id is not touched. */
|
||||
fun ensureAction(exists: Boolean, currentRegistryId: String?, registryId: String): EnsureAction = when {
|
||||
!exists -> EnsureAction.CREATE
|
||||
currentRegistryId == registryId -> EnsureAction.KEEP
|
||||
currentRegistryId.isNullOrEmpty() -> EnsureAction.ADOPT
|
||||
else -> EnsureAction.CONFLICT
|
||||
}
|
||||
|
||||
/** Removes the account; the Contacts and Calendar providers drop all of its rows. */
|
||||
fun remove(context: Context, name: String): Boolean {
|
||||
val manager = AccountManager.get(context)
|
||||
val account = account(context, name)
|
||||
if (manager.getAccountsByType(account.type).none { it.name == name }) return false
|
||||
return manager.removeAccountExplicitly(account)
|
||||
}
|
||||
|
||||
fun setSyncEnabled(context: Context, name: String, authority: String, enabled: Boolean) {
|
||||
requireAuthority(authority)
|
||||
val account = account(context, name)
|
||||
ContentResolver.setIsSyncable(account, authority, 1)
|
||||
ContentResolver.setSyncAutomatically(account, authority, enabled)
|
||||
}
|
||||
|
||||
/** Replaces our periodic sync for the authority; 0 removes it. */
|
||||
fun setPeriodicSync(context: Context, name: String, authority: String, seconds: Long) {
|
||||
requireAuthority(authority)
|
||||
val account = account(context, name)
|
||||
for (periodic in ContentResolver.getPeriodicSyncs(account, authority)) {
|
||||
ContentResolver.removePeriodicSync(account, authority, periodic.extras)
|
||||
}
|
||||
if (seconds > 0) ContentResolver.addPeriodicSync(account, authority, Bundle.EMPTY, seconds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a sync. SyncManager drops a request that matches a sync it is
|
||||
* running, so a run of that account and authority is also flagged to
|
||||
* ask for another sync when it ends (see [DeviceSyncRuns.flagSyncAgain]).
|
||||
*/
|
||||
fun requestSync(context: Context, name: String, authority: String, flags: SyncRequestFlags) {
|
||||
requireAuthority(authority)
|
||||
DeviceSyncRuns.flagSyncAgain(name, authority)
|
||||
ContentResolver.requestSync(account(context, name), authority, flags.toExtras())
|
||||
}
|
||||
|
||||
fun requireAuthority(authority: String) {
|
||||
require(authority in AUTHORITIES) { "Unknown authority $authority" }
|
||||
}
|
||||
}
|
||||
|
||||
/** [DeviceSyncAccounts.ensure] refused to hand an account of one app account to another. */
|
||||
class AccountConflictException(message: String) : Exception(message)
|
||||
|
||||
/** The SYNC_EXTRAS_* flags a request carries. Constant extras let repeated requests coalesce. */
|
||||
data class SyncRequestFlags(
|
||||
val manual: Boolean = false,
|
||||
val expedited: Boolean = false,
|
||||
val upload: Boolean = false,
|
||||
val overrideTooManyDeletions: Boolean = false,
|
||||
val discardLocalDeletions: Boolean = false,
|
||||
) {
|
||||
fun toExtras(): Bundle = Bundle().apply {
|
||||
if (manual) putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true)
|
||||
if (expedited) putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true)
|
||||
if (upload) putBoolean(ContentResolver.SYNC_EXTRAS_UPLOAD, true)
|
||||
if (overrideTooManyDeletions) putBoolean(ContentResolver.SYNC_EXTRAS_OVERRIDE_TOO_MANY_DELETIONS, true)
|
||||
if (discardLocalDeletions) putBoolean(ContentResolver.SYNC_EXTRAS_DISCARD_LOCAL_DELETIONS, true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Application
|
||||
import android.app.Service
|
||||
import android.content.AbstractThreadedSyncAdapter
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.annotation.MainThread
|
||||
import com.anonymous.bulwarkmobile.HeadlessJs
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.WritableMap
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* The sync adapter behind both authorities (#34). It does no syncing itself:
|
||||
* it starts the BulwarkDeviceSync headless JS task, where the engine lives
|
||||
* (src/device-sync), waits for its report and hands that to SyncManager. See
|
||||
* docs/device-sync.md.
|
||||
*
|
||||
* onPerformSync runs on its own SyncAdapterThread and may block; React Native
|
||||
* is started from the main thread (see HeadlessJs), which stays free.
|
||||
* SyncManager holds a wake lock for as long as onPerformSync runs.
|
||||
*/
|
||||
class DeviceSyncAdapter(context: Context) :
|
||||
AbstractThreadedSyncAdapter(context, /* autoInitialize = */ true, /* allowParallelSyncs = */ false) {
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
/**
|
||||
* Anything thrown out of here is rethrown on the sync thread
|
||||
* (AbstractThreadedSyncAdapter's SyncThread) and kills the process, the UI
|
||||
* included. So every failure becomes a result: a soft error SyncManager
|
||||
* retries with back-off, or a database error for a missing permission,
|
||||
* as the framework would report it.
|
||||
*/
|
||||
override fun onPerformSync(
|
||||
account: Account,
|
||||
extras: Bundle,
|
||||
authority: String,
|
||||
provider: ContentProviderClient,
|
||||
syncResult: SyncResult,
|
||||
) {
|
||||
try {
|
||||
performSync(account, extras, authority, syncResult)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Sync of $authority refused by a permission check", e)
|
||||
syncResult.databaseError = true
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "Sync of $authority failed", t)
|
||||
syncResult.stats.numIoExceptions++
|
||||
}
|
||||
}
|
||||
|
||||
private fun performSync(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
|
||||
val registryId = AccountManager.get(context).getUserData(account, DeviceSyncAccounts.USER_DATA_REGISTRY_ID)
|
||||
if (registryId.isNullOrEmpty()) {
|
||||
Log.w(TAG, "Sync of $authority skipped: the account has no app account")
|
||||
syncResult.databaseError = true
|
||||
return
|
||||
}
|
||||
val startedAt = SystemClock.elapsedRealtime()
|
||||
// A sync scheduled as an expedited job (Android 12+) gets 3 minutes
|
||||
// from JobScheduler instead of 10; stay inside either.
|
||||
val expedited = extras.getBoolean(SYNC_EXTRAS_SCHEDULE_AS_EXPEDITED_JOB, false)
|
||||
val deadline = startedAt + if (expedited) EXPEDITED_BUDGET_MS else BUDGET_MS
|
||||
val run = DeviceSyncRuns.open(account.name, authority)
|
||||
main.post { startRun(run, account, registryId, authority, extras, deadline) }
|
||||
try {
|
||||
while (!run.done.await(POLL_MS, TimeUnit.MILLISECONDS)) {
|
||||
val boot = run.boot
|
||||
if (boot != null && boot.isFaulted()) {
|
||||
run.end("React Native did not start: ${boot.getError()?.message}")
|
||||
} else if (SystemClock.elapsedRealtime() >= deadline) {
|
||||
run.end("no report before the deadline")
|
||||
}
|
||||
}
|
||||
val outcome = SyncReports.outcome(run.report, run.endReason)
|
||||
SyncReports.apply(outcome, syncResult)
|
||||
Log.i(TAG, "Sync ${run.runId} of $authority ended (${run.endReason}) after " +
|
||||
"${SystemClock.elapsedRealtime() - startedAt} ms: ${SyncReports.summary(run.report)}; $outcome")
|
||||
} catch (e: InterruptedException) {
|
||||
// onSyncCanceled interrupts this thread. JS sees the run as
|
||||
// cancelled at its next checkpoint (isRunCancelled).
|
||||
run.end("cancelled")
|
||||
Log.i(TAG, "Sync ${run.runId} of $authority cancelled")
|
||||
Thread.currentThread().interrupt()
|
||||
} finally {
|
||||
DeviceSyncRuns.close(run)
|
||||
// After startRun, which was posted first, so a listener it added is removed.
|
||||
main.post { run.detach() }
|
||||
}
|
||||
// A change pushed while the run was open: SyncManager dropped its
|
||||
// request as a duplicate of this sync, so ask for the next one here.
|
||||
if (run.syncAgainRequested) {
|
||||
Log.i(TAG, "Sync ${run.runId} of $authority: changes arrived meanwhile, syncing again")
|
||||
syncResult.fullSyncRequested = true
|
||||
}
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private fun startRun(
|
||||
run: DeviceSyncRuns.Run,
|
||||
account: Account,
|
||||
registryId: String,
|
||||
authority: String,
|
||||
extras: Bundle,
|
||||
deadline: Long,
|
||||
) {
|
||||
if (run.cancelled) return
|
||||
try {
|
||||
run.boot = HeadlessJs.withReadyReactContext(context.applicationContext as Application) { reactContext ->
|
||||
if (run.cancelled) return@withReadyReactContext
|
||||
if (!reactContext.hasActiveReactInstance()) {
|
||||
run.end("the React Native instance is gone")
|
||||
return@withReadyReactContext
|
||||
}
|
||||
val tasks = HeadlessJsTaskContext.getInstance(reactContext)
|
||||
run.attach(tasks, object : HeadlessJsTaskEventListener {
|
||||
override fun onHeadlessJsTaskStart(taskId: Int) = Unit
|
||||
|
||||
// JS settled without a report, timed out, or the context was reloaded.
|
||||
override fun onHeadlessJsTaskFinish(taskId: Int) {
|
||||
if (taskId == run.taskId) run.end("task finished")
|
||||
}
|
||||
})
|
||||
val taskTimeout = (deadline - SystemClock.elapsedRealtime() - LATCH_SLACK_MS).coerceAtLeast(MIN_TASK_MS)
|
||||
val data = Arguments.createMap().apply {
|
||||
putString("runId", run.runId)
|
||||
putString("accountName", account.name)
|
||||
putString("registryId", registryId)
|
||||
putString("authority", authority)
|
||||
putMap("extras", runExtras(extras))
|
||||
putDouble("deadline", (System.currentTimeMillis() + taskTimeout - JS_SLACK_MS).toDouble())
|
||||
}
|
||||
HeadlessJs.startTask(
|
||||
tasks,
|
||||
HeadlessJsTaskConfig(TASK_KEY, data, taskTimeout, /* isAllowedInForeground = */ true),
|
||||
onStarted = { run.taskId = it },
|
||||
onRefused = { run.end("refused: ${it.message}") },
|
||||
)
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
run.end("could not start: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun runExtras(extras: Bundle): WritableMap = Arguments.createMap().apply {
|
||||
fun flag(key: String, name: String) {
|
||||
if (extras.getBoolean(key, false)) putBoolean(name, true)
|
||||
}
|
||||
flag(ContentResolver.SYNC_EXTRAS_MANUAL, "manual")
|
||||
flag(ContentResolver.SYNC_EXTRAS_UPLOAD, "upload")
|
||||
flag(ContentResolver.SYNC_EXTRAS_EXPEDITED, "expedited")
|
||||
flag(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, "ignoreBackoff")
|
||||
flag(ContentResolver.SYNC_EXTRAS_OVERRIDE_TOO_MANY_DELETIONS, "overrideTooManyDeletions")
|
||||
flag(ContentResolver.SYNC_EXTRAS_DISCARD_LOCAL_DELETIONS, "discardLocalDeletions")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BulwarkDeviceSync"
|
||||
const val TASK_KEY = "BulwarkDeviceSync"
|
||||
|
||||
/** ContentResolver.SYNC_EXTRAS_SCHEDULE_AS_EXPEDITED_JOB (API 31). */
|
||||
private const val SYNC_EXTRAS_SCHEDULE_AS_EXPEDITED_JOB = "schedule_as_expedited_job"
|
||||
|
||||
private const val BUDGET_MS = 570_000L // 9.5 min of JobScheduler's 10
|
||||
private const val EXPEDITED_BUDGET_MS = 150_000L // 2.5 min of 3
|
||||
private const val LATCH_SLACK_MS = 20_000L // the task times out this long before our deadline
|
||||
private const val JS_SLACK_MS = 30_000L // JS aims to be done this long before the task timeout
|
||||
private const val MIN_TASK_MS = 30_000L
|
||||
private const val POLL_MS = 500L
|
||||
}
|
||||
}
|
||||
|
||||
/** Binds the contacts authority's adapter (xml/sync_contacts.xml). */
|
||||
class ContactsSyncService : Service() {
|
||||
override fun onBind(intent: Intent): IBinder = adapter(this).syncAdapterBinder
|
||||
|
||||
companion object {
|
||||
@Volatile private var adapter: DeviceSyncAdapter? = null
|
||||
|
||||
private fun adapter(context: Context): DeviceSyncAdapter = adapter ?: synchronized(this) {
|
||||
adapter ?: DeviceSyncAdapter(context.applicationContext).also { adapter = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Binds the calendar authority's adapter (xml/sync_calendar.xml). */
|
||||
class CalendarSyncService : Service() {
|
||||
override fun onBind(intent: Intent): IBinder = adapter(this).syncAdapterBinder
|
||||
|
||||
companion object {
|
||||
@Volatile private var adapter: DeviceSyncAdapter? = null
|
||||
|
||||
private fun adapter(context: Context): DeviceSyncAdapter = adapter ?: synchronized(this) {
|
||||
adapter ?: DeviceSyncAdapter(context.applicationContext).also { adapter = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import com.facebook.react.interfaces.TaskInterface
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskContext
|
||||
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* The device sync runs an adapter is waiting for, keyed by a random run id.
|
||||
*
|
||||
* A run ends exactly once, on the first of: JS calling finishRun, the
|
||||
* headless task ending (JS settled, timed out or was reloaded away), React
|
||||
* refusing or failing to start, the adapter's deadline, or a cancel. A report
|
||||
* that arrives after that is refused whole rather than half-applied. Headless
|
||||
* task ids restart at 1 with every React context, so they never identify a
|
||||
* run; the run id does.
|
||||
*/
|
||||
object DeviceSyncRuns {
|
||||
const val NO_TASK = -1
|
||||
|
||||
enum class State { WAITING, ENDED }
|
||||
|
||||
class Run(val runId: String, val accountName: String, val authority: String) {
|
||||
private val state = AtomicReference(State.WAITING)
|
||||
private val syncAgain = AtomicBoolean(false)
|
||||
val done = CountDownLatch(1)
|
||||
|
||||
/** Written before [done] opens, read after it: the latch orders them. */
|
||||
@Volatile var report: String? = null
|
||||
private set
|
||||
@Volatile var endReason: String? = null
|
||||
private set
|
||||
@Volatile var cancelled = false
|
||||
@Volatile var boot: TaskInterface<Void>? = null
|
||||
/** Written and compared on the UI thread only. */
|
||||
@Volatile var taskId = NO_TASK
|
||||
@Volatile private var tasks: HeadlessJsTaskContext? = null
|
||||
@Volatile private var listener: HeadlessJsTaskEventListener? = null
|
||||
|
||||
val isWaiting: Boolean get() = state.get() == State.WAITING
|
||||
|
||||
/** True once a sync was requested for this account and authority while the run was open. */
|
||||
val syncAgainRequested: Boolean get() = syncAgain.get()
|
||||
|
||||
/** Releases the waiting adapter. Only the first end counts; returns whether this was it. */
|
||||
fun end(reason: String): Boolean = end(reason, null)
|
||||
|
||||
internal fun end(reason: String, reportJson: String?): Boolean {
|
||||
if (!state.compareAndSet(State.WAITING, State.ENDED)) return false
|
||||
report = reportJson
|
||||
endReason = reason
|
||||
done.countDown()
|
||||
return true
|
||||
}
|
||||
|
||||
internal fun requestSyncAgain() {
|
||||
syncAgain.set(true)
|
||||
}
|
||||
|
||||
fun attach(tasks: HeadlessJsTaskContext, listener: HeadlessJsTaskEventListener) {
|
||||
this.tasks = tasks
|
||||
this.listener = listener
|
||||
tasks.addTaskEventListener(listener)
|
||||
}
|
||||
|
||||
fun detach() {
|
||||
val tasks = tasks ?: return
|
||||
listener?.let { tasks.removeTaskEventListener(it) }
|
||||
this.tasks = null
|
||||
this.listener = null
|
||||
}
|
||||
}
|
||||
|
||||
private val runs = ConcurrentHashMap<String, Run>()
|
||||
|
||||
fun open(accountName: String, authority: String): Run =
|
||||
Run(UUID.randomUUID().toString(), accountName, authority).also { runs[it.runId] = it }
|
||||
|
||||
/**
|
||||
* Forgets the run: a late finishRun is then refused and isCancelled
|
||||
* answers true. Read [Run.syncAgainRequested] after this, so a request
|
||||
* can no longer slip in between reading the flag and closing.
|
||||
*/
|
||||
fun close(run: Run) {
|
||||
run.end("closed")
|
||||
run.cancelled = true
|
||||
runs.remove(run.runId)
|
||||
}
|
||||
|
||||
/** From JS. Accepted only while the adapter still waits for this run: false when it ended, timed out or is unknown. */
|
||||
fun finish(runId: String, reportJson: String): Boolean =
|
||||
runs[runId]?.end("reported", reportJson) ?: false
|
||||
|
||||
/** Runs nobody waits for, or that already ended, count as cancelled: JS should stop. */
|
||||
fun isCancelled(runId: String): Boolean {
|
||||
val run = runs[runId] ?: return true
|
||||
return run.cancelled || !run.isWaiting
|
||||
}
|
||||
|
||||
fun cancel(runId: String) {
|
||||
runs[runId]?.cancelled = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags the open runs of [accountName] and [authority] to request another
|
||||
* sync when they end (`SyncResult.fullSyncRequested`): SyncManager drops a
|
||||
* `requestSync` that matches a sync it is running, so a change pushed
|
||||
* mid-run would otherwise wait for the next trigger. Returns whether a
|
||||
* run was flagged.
|
||||
*/
|
||||
fun flagSyncAgain(accountName: String, authority: String): Boolean {
|
||||
var flagged = false
|
||||
for (run in runs.values) {
|
||||
if (run.accountName == accountName && run.authority == authority) {
|
||||
run.requestSyncAgain()
|
||||
flagged = true
|
||||
}
|
||||
}
|
||||
return flagged
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.OperationApplicationException
|
||||
import android.database.Cursor
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.TransactionTooLargeException
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.SyncStateContract
|
||||
import android.util.Base64
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
/**
|
||||
* Runs device sync's provider calls for one Android account of our type and
|
||||
* one authority: `query`, `applyBatch`, `readSyncState` and `readPhoto` of
|
||||
* `NativeModules.BulwarkDeviceSync`. [ScopePlanner] decides what may run; this
|
||||
* class only builds the calls and reads the answers.
|
||||
*
|
||||
* Every URI carries `caller_is_syncadapter=true` and the account
|
||||
* (CalendarProvider accepts no other query parameter), so writes neither set
|
||||
* DIRTY nor schedule upload syncs, and ContactsProvider filters data rows by
|
||||
* the account. The scope lookups are the exception: they read ids and owners
|
||||
* of rows of any account.
|
||||
*
|
||||
* The client is unstable: a provider process that dies fails the call instead
|
||||
* of taking this process (and the UI) with it. Call from a background thread;
|
||||
* close when done.
|
||||
*/
|
||||
class ProviderIo(context: Context, accountName: String, private val authority: String) : ScopeLookup, AutoCloseable {
|
||||
private val appContext: Context = context.applicationContext
|
||||
private val account: AccountRef
|
||||
private val client: ContentProviderClient
|
||||
|
||||
init {
|
||||
if (authority !in DeviceSyncAccounts.AUTHORITIES) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "$authority is not a device sync authority")
|
||||
}
|
||||
account = AccountRef(accountName, DeviceSyncAccounts.accountType(appContext))
|
||||
client = appContext.contentResolver.acquireUnstableContentProviderClient(authority)
|
||||
?: throw IllegalStateException("No content provider for $authority")
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
client.close()
|
||||
}
|
||||
|
||||
// ── the bridge calls ────────────────────────────────────────
|
||||
|
||||
/** `ProviderQuery` JSON in, `ProviderRows` JSON out. Throws [ProviderRefusal] or what the provider threw. */
|
||||
fun query(queryJson: String): String {
|
||||
val plan = ScopePlanner(account, this).planQuery(ProviderOpParser.parseQuery(authority, queryJson))
|
||||
val writer = ProviderRowsWriter(plan.columns)
|
||||
val projection = plan.columns.toTypedArray()
|
||||
for (selection in plan.selections) {
|
||||
query(uri(plan.table), projection, selection, plan.orderBy).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
writer.beginRow()
|
||||
for (i in projection.indices) {
|
||||
when (cursor.getType(i)) {
|
||||
Cursor.FIELD_TYPE_INTEGER -> writer.longCell(cursor.getLong(i))
|
||||
Cursor.FIELD_TYPE_FLOAT -> writer.doubleCell(cursor.getDouble(i))
|
||||
Cursor.FIELD_TYPE_STRING -> writer.textCell(cursor.getString(i))
|
||||
// Blobs (photo thumbnails) never cross the bridge; readPhoto reads them.
|
||||
else -> writer.nullCell()
|
||||
}
|
||||
}
|
||||
writer.endRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
return writer.finish()
|
||||
}
|
||||
|
||||
/** `ProviderOp[]` JSON in, `BatchResult` JSON out. Never throws: failures are results. */
|
||||
fun applyBatch(opsJson: String): String = try {
|
||||
val ops = ProviderOpParser.parseBatch(authority, opsJson)
|
||||
// Rows written for an account that is gone would outlive it: the
|
||||
// providers purge an account's rows only when accounts change.
|
||||
if (!DeviceSyncAccounts.exists(appContext, account.name)) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "The account ${account.name} no longer exists")
|
||||
}
|
||||
if (ops.isEmpty()) {
|
||||
ProviderResults.ok(emptyList())
|
||||
} else {
|
||||
val planned = ScopePlanner(account, this).planBatch(ops)
|
||||
val results = client.applyBatch(ArrayList(planned.map(::build)))
|
||||
ProviderResults.ok(planned.zip(results) { op, result -> outcome(op, result.uri, result.count) })
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
ProviderResults.failed(failureOf(e), e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
|
||||
/** The account's SyncState blob as text, or null when there is none. */
|
||||
fun readSyncState(): String? =
|
||||
SyncStateContract.Helpers.get(client, syncStateUri(), Account(account.name, account.type))
|
||||
?.toString(Charsets.UTF_8)
|
||||
|
||||
/**
|
||||
* The raw contact's display photo, or its thumbnail when it has none,
|
||||
* scaled so the longer side is at most [maxPx], as `PhotoData` JSON; null
|
||||
* when the raw contact has no photo.
|
||||
*/
|
||||
fun readPhoto(rawContactId: Long, maxPx: Int): String? {
|
||||
require(authority == DeviceSyncAccounts.CONTACTS_AUTHORITY) { "Photos are contacts data" }
|
||||
require(maxPx > 0) { "maxPx must be positive" }
|
||||
if (rootAccounts(ProviderTable.RAW_CONTACTS, setOf(rawContactId))[rawContactId] != account) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "Raw contact $rawContactId is not part of the account")
|
||||
}
|
||||
var fileId: Long? = null
|
||||
var thumbnail: ByteArray? = null
|
||||
val photoRow = Selection(
|
||||
"raw_contact_id = ? AND mimetype = ?",
|
||||
listOf(rawContactId.toString(), ContactsContract.CommonDataKinds.Photo.CONTENT_ITEM_TYPE),
|
||||
)
|
||||
query(
|
||||
uri(ProviderTable.DATA),
|
||||
arrayOf(ContactsContract.CommonDataKinds.Photo.PHOTO_FILE_ID, ContactsContract.CommonDataKinds.Photo.PHOTO),
|
||||
photoRow,
|
||||
"${ContactsContract.Data.IS_PRIMARY} DESC",
|
||||
).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
if (!cursor.isNull(0)) fileId = cursor.getLong(0)
|
||||
if (cursor.getType(1) == Cursor.FIELD_TYPE_BLOB) thumbnail = cursor.getBlob(1)
|
||||
}
|
||||
}
|
||||
val bytes = readDisplayPhoto(rawContactId) ?: thumbnail ?: return null
|
||||
val jpeg = scaledJpeg(bytes, maxPx) ?: return null
|
||||
return JSONObject()
|
||||
.put("jpegBase64", Base64.encodeToString(jpeg, Base64.NO_WRAP))
|
||||
.put("fileId", fileId ?: JSONObject.NULL)
|
||||
.toString()
|
||||
}
|
||||
|
||||
private fun readDisplayPhoto(rawContactId: Long): ByteArray? {
|
||||
val uri = ContentUris.withAppendedId(ContactsContract.RawContacts.CONTENT_URI, rawContactId).buildUpon()
|
||||
.appendPath(ContactsContract.RawContacts.DisplayPhoto.CONTENT_DIRECTORY)
|
||||
.scoped()
|
||||
.build()
|
||||
return try {
|
||||
client.openAssetFile(uri, "r")?.use { fd -> fd.createInputStream().use { it.readBytes() } }
|
||||
} catch (e: FileNotFoundException) {
|
||||
// No display photo stored (small photos only have the thumbnail).
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun scaledJpeg(bytes: ByteArray, maxPx: Int): ByteArray? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inSampleSize = PhotoScaling.sampleSize(bounds.outWidth, bounds.outHeight, maxPx)
|
||||
}
|
||||
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) ?: return null
|
||||
val (width, height) = PhotoScaling.targetSize(decoded.width, decoded.height, maxPx)
|
||||
val scaled = if (width == decoded.width && height == decoded.height) {
|
||||
decoded
|
||||
} else {
|
||||
Bitmap.createScaledBitmap(decoded, width, height, true)
|
||||
}
|
||||
return ByteArrayOutputStream().use { out ->
|
||||
scaled.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out)
|
||||
out.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
// ── ScopeLookup ─────────────────────────────────────────────
|
||||
|
||||
override fun ownCalendarIds(): Set<Long> {
|
||||
val ids = HashSet<Long>()
|
||||
val selection = Selection(
|
||||
"${ScopePlanner.ACCOUNT_NAME} = ? AND ${ScopePlanner.ACCOUNT_TYPE} = ?",
|
||||
listOf(account.name, account.type),
|
||||
)
|
||||
query(uri(ProviderTable.CALENDARS), arrayOf(ID), selection, null).use { c ->
|
||||
while (c.moveToNext()) ids += c.getLong(0)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
override fun rootAccounts(table: ProviderTable, ids: Set<Long>): Map<Long, AccountRef> {
|
||||
val owners = HashMap<Long, AccountRef>()
|
||||
forEachRow(table, arrayOf(ID, ScopePlanner.ACCOUNT_NAME, ScopePlanner.ACCOUNT_TYPE), ID, ids) { c ->
|
||||
owners[c.getLong(0)] = AccountRef(c.getString(1).orEmpty(), c.getString(2).orEmpty())
|
||||
}
|
||||
return owners
|
||||
}
|
||||
|
||||
override fun dataRawContacts(ids: Set<Long>): Map<Long, Long> =
|
||||
idPairs(ProviderTable.DATA, "raw_contact_id", ids)
|
||||
|
||||
override fun dataMimetypes(ids: Set<Long>): Map<Long, String> {
|
||||
val mimetypes = HashMap<Long, String>()
|
||||
forEachRow(ProviderTable.DATA, arrayOf(ID, "mimetype"), ID, ids) { c ->
|
||||
if (!c.isNull(1)) mimetypes[c.getLong(0)] = c.getString(1)
|
||||
}
|
||||
return mimetypes
|
||||
}
|
||||
|
||||
override fun eventCalendars(ids: Set<Long>): Map<Long, Long> =
|
||||
idPairs(ProviderTable.EVENTS, "calendar_id", ids)
|
||||
|
||||
override fun childEvents(table: ProviderTable, ids: Set<Long>): Map<Long, Long> =
|
||||
idPairs(table, "event_id", ids)
|
||||
|
||||
override fun childEventIdsMatching(table: ProviderTable, where: String?, args: List<String>): Set<Long> {
|
||||
val events = HashSet<Long>()
|
||||
query(uri(table, scoped = false), arrayOf("event_id"), Selection(where, args), null).use { c ->
|
||||
while (c.moveToNext()) if (!c.isNull(0)) events += c.getLong(0)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/** Row id → [column] for the rows of [ids] that exist, in any account. */
|
||||
private fun idPairs(table: ProviderTable, column: String, ids: Set<Long>): Map<Long, Long> {
|
||||
val pairs = HashMap<Long, Long>()
|
||||
forEachRow(table, arrayOf(ID, column), table.idColumn!!, ids) { c ->
|
||||
if (!c.isNull(1)) pairs[c.getLong(0)] = c.getLong(1)
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
private fun forEachRow(
|
||||
table: ProviderTable,
|
||||
projection: Array<String>,
|
||||
idColumn: String,
|
||||
ids: Set<Long>,
|
||||
read: (Cursor) -> Unit,
|
||||
) {
|
||||
for (chunk in ids.chunked(ScopePlanner.ID_LIST_CHUNK)) {
|
||||
val selection = Selection(ScopePlanner.idList(idColumn, chunk), emptyList())
|
||||
query(uri(table, scoped = false), projection, selection, null).use { c ->
|
||||
while (c.moveToNext()) read(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun query(uri: Uri, projection: Array<String>, selection: Selection, orderBy: String?): Cursor =
|
||||
client.query(uri, projection, selection.sql, selection.args.toTypedArray(), orderBy)
|
||||
?: throw IllegalStateException("$authority returned no cursor for $uri")
|
||||
|
||||
// ── operations ──────────────────────────────────────────────
|
||||
|
||||
private fun build(op: PlannedOp): ContentProviderOperation = when (op) {
|
||||
is PlannedOp.Insert -> ContentProviderOperation.newInsert(uri(op.table))
|
||||
.withValues(contentValues(op.values))
|
||||
.apply { for ((column, index) in op.backReferences) withValueBackReference(column, index) }
|
||||
.withYieldAllowed(op.yieldAllowed)
|
||||
.build()
|
||||
is PlannedOp.Update -> ContentProviderOperation
|
||||
.newUpdate(op.itemId?.let { uri(op.table, itemId = it) } ?: uri(op.table))
|
||||
.withValues(contentValues(op.values))
|
||||
.selectRows(op.selection)
|
||||
.expectRows(op.expectedCount)
|
||||
.withYieldAllowed(op.yieldAllowed)
|
||||
.build()
|
||||
is PlannedOp.Delete -> ContentProviderOperation.newDelete(uri(op.table))
|
||||
.selectRows(op.selection)
|
||||
.expectRows(op.expectedCount)
|
||||
.withYieldAllowed(op.yieldAllowed)
|
||||
.build()
|
||||
is PlannedOp.Assert -> ContentProviderOperation.newAssertQuery(uri(op.table))
|
||||
.selectRows(op.selection)
|
||||
.apply {
|
||||
op.values?.let { values ->
|
||||
withValues(ContentValues().apply { for ((column, text) in values) put(column, text) })
|
||||
}
|
||||
}
|
||||
.expectRows(op.expectedCount)
|
||||
.withYieldAllowed(op.yieldAllowed)
|
||||
.build()
|
||||
// An insert that replaces the account's row; never a yield point.
|
||||
is PlannedOp.SetSyncState -> SyncStateContract.Helpers.newSetOperation(
|
||||
syncStateUri(),
|
||||
Account(account.name, account.type),
|
||||
op.value.toByteArray(Charsets.UTF_8),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ContentProviderOperation.Builder.selectRows(selection: Selection) = apply {
|
||||
// An item URI takes no selection at all (CalendarProvider refuses one).
|
||||
if (selection.sql != null) withSelection(selection.sql, selection.args.toTypedArray())
|
||||
}
|
||||
|
||||
private fun ContentProviderOperation.Builder.expectRows(count: Int?) = apply {
|
||||
if (count != null) withExpectedCount(count)
|
||||
}
|
||||
|
||||
private fun outcome(op: PlannedOp, uri: Uri?, count: Int?): OpOutcome = when (op) {
|
||||
// Settings inserts answer with the account's URI, which has no id.
|
||||
is PlannedOp.Insert -> OpOutcome(id = uri?.lastPathSegment?.toLongOrNull())
|
||||
is PlannedOp.SetSyncState -> OpOutcome()
|
||||
else -> OpOutcome(count = count)
|
||||
}
|
||||
|
||||
private fun contentValues(values: Map<String, Cell>) = ContentValues(values.size).apply {
|
||||
for ((column, cell) in values) {
|
||||
when (cell) {
|
||||
is Cell.Text -> put(column, cell.value)
|
||||
is Cell.Integer -> put(column, cell.value)
|
||||
is Cell.Real -> put(column, cell.value)
|
||||
is Cell.Blob -> put(column, decodeBlob(column, cell.base64))
|
||||
Cell.Null -> putNull(column)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeBlob(column: String, base64: String): ByteArray = try {
|
||||
Base64.decode(base64, Base64.DEFAULT)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
throw ProviderRefusal(BatchFailure.PROVIDER, "$column is not base64")
|
||||
}
|
||||
|
||||
// ── URIs ────────────────────────────────────────────────────
|
||||
|
||||
private fun uri(table: ProviderTable, itemId: Long? = null, scoped: Boolean = true): Uri {
|
||||
val base = when (table) {
|
||||
ProviderTable.RAW_CONTACTS -> ContactsContract.RawContacts.CONTENT_URI
|
||||
ProviderTable.DATA -> ContactsContract.Data.CONTENT_URI
|
||||
ProviderTable.GROUPS -> ContactsContract.Groups.CONTENT_URI
|
||||
ProviderTable.SETTINGS -> ContactsContract.Settings.CONTENT_URI
|
||||
ProviderTable.CALENDARS -> CalendarContract.Calendars.CONTENT_URI
|
||||
ProviderTable.EVENTS -> CalendarContract.Events.CONTENT_URI
|
||||
ProviderTable.ATTENDEES -> CalendarContract.Attendees.CONTENT_URI
|
||||
ProviderTable.REMINDERS -> CalendarContract.Reminders.CONTENT_URI
|
||||
ProviderTable.EXTENDED_PROPERTIES -> CalendarContract.ExtendedProperties.CONTENT_URI
|
||||
ProviderTable.COLORS -> CalendarContract.Colors.CONTENT_URI
|
||||
}
|
||||
val builder = (itemId?.let { ContentUris.withAppendedId(base, it) } ?: base).buildUpon()
|
||||
return if (scoped) builder.scoped().build() else builder.syncAdapter().build()
|
||||
}
|
||||
|
||||
private fun syncStateUri(): Uri = when (authority) {
|
||||
DeviceSyncAccounts.CONTACTS_AUTHORITY -> ContactsContract.SyncState.CONTENT_URI
|
||||
else -> CalendarContract.SyncState.CONTENT_URI
|
||||
}.buildUpon().scoped().build()
|
||||
|
||||
private fun Uri.Builder.syncAdapter() = appendQueryParameter(CALLER_IS_SYNCADAPTER, "true")
|
||||
|
||||
private fun Uri.Builder.scoped() = syncAdapter()
|
||||
.appendQueryParameter(ScopePlanner.ACCOUNT_NAME, account.name)
|
||||
.appendQueryParameter(ScopePlanner.ACCOUNT_TYPE, account.type)
|
||||
|
||||
companion object {
|
||||
private const val ID = "_id"
|
||||
private const val JPEG_QUALITY = 85
|
||||
|
||||
/** Same parameter name in ContactsContract and CalendarContract. */
|
||||
private const val CALLER_IS_SYNCADAPTER = ContactsContract.CALLER_IS_SYNCADAPTER
|
||||
|
||||
/** The `BatchFailure` of an exception from planning or from the provider (possibly wrapped). */
|
||||
fun failureOf(e: Throwable): BatchFailure {
|
||||
var t: Throwable? = e
|
||||
while (t != null) {
|
||||
when (t) {
|
||||
is ProviderRefusal -> return t.reason
|
||||
is TransactionTooLargeException -> return BatchFailure.TOO_LARGE
|
||||
is SecurityException -> return BatchFailure.PERMISSION
|
||||
is OperationApplicationException -> return ProviderResults.failureOfOperationApplication(t.message)
|
||||
}
|
||||
t = t.cause?.takeIf { it !== t }
|
||||
}
|
||||
return BatchFailure.PROVIDER
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* The provider operations of device sync (#34) as the engine sends them: the
|
||||
* `ProviderQuery` and `ProviderOp[]` JSON of src/device-sync/types.ts, parsed
|
||||
* into a validated model. [ScopePlanner] turns the model into provider calls
|
||||
* scoped to one account and [ProviderIo] runs them. Nothing here touches
|
||||
* Android types, so JVM tests cover all of it.
|
||||
*/
|
||||
|
||||
/** `BatchFailure` of types.ts: why a batch was refused or failed. */
|
||||
enum class BatchFailure(val wire: String) {
|
||||
ASSERT("assert"),
|
||||
TOO_LARGE("tooLarge"),
|
||||
SCOPE("scope"),
|
||||
PERMISSION("permission"),
|
||||
PROVIDER("provider"),
|
||||
}
|
||||
|
||||
/** A query or batch refused before it reached the provider. */
|
||||
class ProviderRefusal(val reason: BatchFailure, message: String) : Exception(message)
|
||||
|
||||
/** How the rows of a table are tied to an account; see [ScopePlanner]. */
|
||||
enum class TableScope { ACCOUNT_COLUMNS, URI_PARAMETERS, CALENDAR_IDS, EVENT_IDS }
|
||||
|
||||
/** The tables the engine may touch, per authority. Any other table is refused. */
|
||||
enum class ProviderTable(
|
||||
val wire: String,
|
||||
val authority: String,
|
||||
val scope: TableScope,
|
||||
/** The table whose row each row here belongs to, named by [parentColumn]. */
|
||||
val parent: ProviderTable? = null,
|
||||
val parentColumn: String? = null,
|
||||
/**
|
||||
* How a selection names the row id; null when rows have none (Settings
|
||||
* rows are keyed by their account). CalendarProvider queries attendees
|
||||
* joined with events and calendars, where a bare `_id` is ambiguous.
|
||||
*/
|
||||
val idColumn: String? = "_id",
|
||||
) {
|
||||
RAW_CONTACTS("raw_contacts", DeviceSyncAccounts.CONTACTS_AUTHORITY, TableScope.ACCOUNT_COLUMNS),
|
||||
DATA("data", DeviceSyncAccounts.CONTACTS_AUTHORITY, TableScope.URI_PARAMETERS, RAW_CONTACTS, "raw_contact_id"),
|
||||
GROUPS("groups", DeviceSyncAccounts.CONTACTS_AUTHORITY, TableScope.ACCOUNT_COLUMNS),
|
||||
SETTINGS("settings", DeviceSyncAccounts.CONTACTS_AUTHORITY, TableScope.ACCOUNT_COLUMNS, idColumn = null),
|
||||
CALENDARS("calendars", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.ACCOUNT_COLUMNS),
|
||||
EVENTS("events", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.CALENDAR_IDS, CALENDARS, "calendar_id"),
|
||||
ATTENDEES(
|
||||
"attendees", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.EVENT_IDS, EVENTS, "event_id", "Attendees._id",
|
||||
),
|
||||
REMINDERS(
|
||||
"reminders", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.EVENT_IDS, EVENTS, "event_id", "Reminders._id",
|
||||
),
|
||||
EXTENDED_PROPERTIES(
|
||||
"extended_properties",
|
||||
DeviceSyncAccounts.CALENDAR_AUTHORITY,
|
||||
TableScope.EVENT_IDS,
|
||||
EVENTS,
|
||||
"event_id",
|
||||
"ExtendedProperties._id",
|
||||
),
|
||||
COLORS("colors", DeviceSyncAccounts.CALENDAR_AUTHORITY, TableScope.ACCOUNT_COLUMNS),
|
||||
;
|
||||
|
||||
/** An insert returns the new row id, which later ops can back-reference. */
|
||||
val insertReturnsId: Boolean get() = idColumn != null
|
||||
|
||||
companion object {
|
||||
fun of(authority: String, wire: String): ProviderTable =
|
||||
entries.firstOrNull { it.wire == wire && it.authority == authority }
|
||||
?: throw ProviderRefusal(BatchFailure.SCOPE, "Table $wire is not part of $authority")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A cell as it crosses the bridge: text, a number, null, or (written to
|
||||
* `data.data15` only) photo bytes as base64. Numbers that are integers become
|
||||
* [Integer], so an assert compares `5` with the provider's `5`, not `5.0`.
|
||||
*/
|
||||
sealed interface Cell {
|
||||
data class Text(val value: String) : Cell
|
||||
data class Integer(val value: Long) : Cell
|
||||
data class Real(val value: Double) : Cell
|
||||
data class Blob(val base64: String) : Cell
|
||||
data object Null : Cell
|
||||
|
||||
/** The text ContentProviderOperation compares an assert with; blobs never reach an assert. */
|
||||
fun text(): String? = when (this) {
|
||||
is Text -> value
|
||||
is Integer -> value.toString()
|
||||
is Real -> value.toString()
|
||||
is Blob -> throw IllegalStateException("Blobs have no text form")
|
||||
Null -> null
|
||||
}
|
||||
|
||||
/** The row id this cell names, if it names one (`12` or `"12"`). */
|
||||
fun rowId(): Long? = when (this) {
|
||||
is Integer -> value
|
||||
is Text -> value.toLongOrNull()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** Which rows an update, delete or assert addresses: one row by id, a `where` set, or the id row if it matches. */
|
||||
data class RowTarget(val id: Long?, val where: String?, val args: List<String>)
|
||||
|
||||
data class ProviderQuery(
|
||||
val table: ProviderTable,
|
||||
val columns: List<String>,
|
||||
val where: String?,
|
||||
val args: List<String>,
|
||||
val orderBy: String?,
|
||||
)
|
||||
|
||||
/** One `ProviderOp` of types.ts, validated. */
|
||||
sealed interface ProviderOp {
|
||||
val yieldAllowed: Boolean
|
||||
|
||||
data class Insert(
|
||||
val table: ProviderTable,
|
||||
val values: Map<String, Cell>,
|
||||
/** Column → index of an earlier insert of the batch whose new row id fills it. */
|
||||
val refs: Map<String, Int>,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : ProviderOp
|
||||
|
||||
data class Update(
|
||||
val table: ProviderTable,
|
||||
val target: RowTarget,
|
||||
val values: Map<String, Cell>,
|
||||
val expectCount: Int?,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : ProviderOp
|
||||
|
||||
data class Delete(
|
||||
val table: ProviderTable,
|
||||
val target: RowTarget,
|
||||
val expectCount: Int?,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : ProviderOp
|
||||
|
||||
data class Assert(
|
||||
val table: ProviderTable,
|
||||
val target: RowTarget,
|
||||
val values: Map<String, Cell>?,
|
||||
val expectCount: Int?,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : ProviderOp
|
||||
|
||||
data class SetSyncState(val value: String, override val yieldAllowed: Boolean) : ProviderOp
|
||||
}
|
||||
|
||||
const val GROUP_MEMBERSHIP_MIMETYPE = "vnd.android.cursor.item/group_membership"
|
||||
|
||||
/**
|
||||
* Checks a selection or sort order before the account's scope is ANDed onto
|
||||
* it as `(text) AND (scope)`: its parentheses must balance outside quoted
|
||||
* literals, or `1) OR (1` would close the scope's own parenthesis. It may
|
||||
* not end or comment out the statement, nor query other tables. The engine's
|
||||
* selections are constants with `?` arguments; this guards against bugs.
|
||||
*/
|
||||
object SqlFragments {
|
||||
private val FORBIDDEN_WORDS = Regex("\\b(SELECT|UNION|ATTACH|DETACH|PRAGMA)\\b", RegexOption.IGNORE_CASE)
|
||||
|
||||
fun check(text: String, what: String) {
|
||||
var depth = 0
|
||||
var quote: Char? = null
|
||||
val outside = StringBuilder(text.length)
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
val c = text[i]
|
||||
if (quote != null) {
|
||||
if (c == quote) {
|
||||
// A doubled quote is an escaped one inside the literal.
|
||||
if (i + 1 < text.length && text[i + 1] == quote) i++ else quote = null
|
||||
}
|
||||
outside.append(' ')
|
||||
} else {
|
||||
when (c) {
|
||||
'\'', '"' -> {
|
||||
quote = c
|
||||
outside.append(' ')
|
||||
}
|
||||
'(' -> {
|
||||
depth++
|
||||
outside.append(c)
|
||||
}
|
||||
')' -> {
|
||||
if (--depth < 0) refuse(what, "closes a parenthesis it did not open")
|
||||
outside.append(c)
|
||||
}
|
||||
';' -> refuse(what, "ends the statement")
|
||||
'\u0000' -> refuse(what, "contains a NUL character")
|
||||
else -> outside.append(c)
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
if (quote != null) refuse(what, "leaves a quote open")
|
||||
if (depth != 0) refuse(what, "leaves a parenthesis open")
|
||||
val bare = outside.toString()
|
||||
if ("--" in bare || "/*" in bare) refuse(what, "contains a comment")
|
||||
FORBIDDEN_WORDS.find(bare)?.let { refuse(what, "uses ${it.value.uppercase()}") }
|
||||
}
|
||||
|
||||
private fun refuse(what: String, why: String): Nothing =
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "$what $why")
|
||||
}
|
||||
|
||||
/**
|
||||
* The table a written column points into when the row it names must belong to
|
||||
* the account as well: the parent of a row, the master of an exception event,
|
||||
* the group of a membership row.
|
||||
*/
|
||||
internal fun referencedTable(table: ProviderTable, column: String, values: Map<String, Cell>): ProviderTable? = when {
|
||||
column == table.parentColumn -> table.parent
|
||||
table == ProviderTable.EVENTS && column == "original_id" -> ProviderTable.EVENTS
|
||||
table == ProviderTable.DATA && column == "data1" &&
|
||||
values["mimetype"] == Cell.Text(GROUP_MEMBERSHIP_MIMETYPE) -> ProviderTable.GROUPS
|
||||
else -> null
|
||||
}
|
||||
|
||||
object ProviderOpParser {
|
||||
/**
|
||||
* ContactsProvider throws when its op counter reaches this. It counts an
|
||||
* op before resetting at a yield point, so at most 499 ops may follow a
|
||||
* yield point up to and including the next one.
|
||||
*/
|
||||
const val CONTACTS_OPS_PER_YIELD_POINT = 500
|
||||
|
||||
/** JS numbers beyond this are not exact integers any more. */
|
||||
private const val MAX_SAFE_INTEGER = 9_007_199_254_740_991.0
|
||||
|
||||
fun parseQuery(authority: String, json: String): ProviderQuery {
|
||||
val o = parseObject(json, "query")
|
||||
val table = ProviderTable.of(authority, o.requireString("table"))
|
||||
val rawColumns = o.opt("columns") as? JSONArray ?: throw invalid("query needs a columns array")
|
||||
val columns = (0 until rawColumns.length()).map { i ->
|
||||
rawColumns.opt(i) as? String ?: throw invalid("columns[$i] must be a string")
|
||||
}
|
||||
if (columns.isEmpty()) throw invalid("query needs at least one column")
|
||||
val (where, args) = o.whereArgs("query")
|
||||
val orderBy = o.optionalString("orderBy")?.also { SqlFragments.check(it, "query: orderBy") }
|
||||
return ProviderQuery(table, columns, where, args, orderBy)
|
||||
}
|
||||
|
||||
fun parseBatch(authority: String, json: String): List<ProviderOp> {
|
||||
val array = try {
|
||||
JSONArray(json)
|
||||
} catch (e: JSONException) {
|
||||
throw invalid("ops are not a JSON array: ${e.message}")
|
||||
}
|
||||
val ops = (0 until array.length()).map { i ->
|
||||
val o = array.opt(i) as? JSONObject ?: throw invalid("op $i is not an object")
|
||||
parseOp(authority, i, o)
|
||||
}
|
||||
checkSyncState(ops)
|
||||
checkBackReferences(ops)
|
||||
if (authority == DeviceSyncAccounts.CONTACTS_AUTHORITY) checkYieldPoints(ops)
|
||||
return ops
|
||||
}
|
||||
|
||||
private fun parseOp(authority: String, index: Int, o: JSONObject): ProviderOp {
|
||||
val where = "op $index"
|
||||
val kind = o.opt("op") as? String ?: throw invalid("$where has no op")
|
||||
val yieldAllowed = o.optBoolean("yieldAllowed", false)
|
||||
if (kind == "syncState") {
|
||||
val value = o.opt("value") as? String ?: throw invalid("$where: syncState needs a string value")
|
||||
return ProviderOp.SetSyncState(value, yieldAllowed)
|
||||
}
|
||||
val table = ProviderTable.of(authority, o.requireString("table"))
|
||||
return when (kind) {
|
||||
"insert" -> ProviderOp.Insert(table, o.writeValues(table, where), o.refs(where), yieldAllowed)
|
||||
"update" -> {
|
||||
val values = o.writeValues(table, where)
|
||||
if (values.isEmpty()) throw invalid("$where: an update needs values")
|
||||
ProviderOp.Update(table, o.target(table, where), values, o.expectCount(where), yieldAllowed)
|
||||
}
|
||||
"delete" -> ProviderOp.Delete(table, o.target(table, where), o.expectCount(where), yieldAllowed)
|
||||
"assert" -> {
|
||||
val target = o.target(table, where)
|
||||
val values = o.assertValues(where)
|
||||
val expectCount = o.expectCount(where)
|
||||
if (values.isNullOrEmpty() && expectCount == null && target.id == null) {
|
||||
throw invalid("$where: an assert needs values, an expected count or an id")
|
||||
}
|
||||
ProviderOp.Assert(table, target, values?.takeIf { it.isNotEmpty() }, expectCount, yieldAllowed)
|
||||
}
|
||||
else -> throw invalid("$where: unknown op $kind")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The state describes the rows of its batch, so it must commit with them:
|
||||
* as the last op, and not at a yield point, where the provider may commit
|
||||
* everything before it on its own.
|
||||
*/
|
||||
private fun checkSyncState(ops: List<ProviderOp>) {
|
||||
ops.forEachIndexed { i, op ->
|
||||
if (op !is ProviderOp.SetSyncState) return@forEachIndexed
|
||||
if (i != ops.lastIndex) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "op $i: syncState must be the last op of its batch")
|
||||
}
|
||||
if (op.yieldAllowed) throw ProviderRefusal(BatchFailure.SCOPE, "op $i: syncState must not be a yield point")
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkBackReferences(ops: List<ProviderOp>) {
|
||||
ops.forEachIndexed { i, op ->
|
||||
if (op !is ProviderOp.Insert) return@forEachIndexed
|
||||
for ((column, ref) in op.refs) {
|
||||
val target = ops.getOrNull(ref)
|
||||
if (ref >= i || target !is ProviderOp.Insert || !target.table.insertReturnsId) {
|
||||
throw invalid("op $i: $column refers to op $ref, which is not an earlier insert with a row id")
|
||||
}
|
||||
val expected = referencedTable(op.table, column, op.values)
|
||||
if (expected != null && target.table != expected) {
|
||||
throw ProviderRefusal(
|
||||
BatchFailure.SCOPE,
|
||||
"op $i: $column must refer to a ${expected.wire} insert, not ${target.table.wire}",
|
||||
)
|
||||
}
|
||||
}
|
||||
val parentColumn = op.table.parentColumn ?: return@forEachIndexed
|
||||
val named = parentColumn in op.refs || (op.values[parentColumn] ?: Cell.Null) != Cell.Null
|
||||
if (!named) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "op $i: a ${op.table.wire} insert must name its $parentColumn")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkYieldPoints(ops: List<ProviderOp>) {
|
||||
var count = 0
|
||||
ops.forEachIndexed { i, op ->
|
||||
if (++count >= CONTACTS_OPS_PER_YIELD_POINT) {
|
||||
throw invalid(
|
||||
"op $i: more than ${CONTACTS_OPS_PER_YIELD_POINT - 1} ops without a yield point " +
|
||||
"(ContactsProvider refuses the batch)",
|
||||
)
|
||||
}
|
||||
if (i > 0 && op.yieldAllowed) count = 0
|
||||
}
|
||||
}
|
||||
|
||||
// ── JSON helpers ────────────────────────────────────────────
|
||||
|
||||
private fun invalid(message: String) = ProviderRefusal(BatchFailure.PROVIDER, message)
|
||||
|
||||
private fun parseObject(json: String, what: String): JSONObject = try {
|
||||
JSONObject(json)
|
||||
} catch (e: JSONException) {
|
||||
throw invalid("$what is not a JSON object: ${e.message}")
|
||||
}
|
||||
|
||||
private fun JSONObject.requireString(key: String): String =
|
||||
opt(key) as? String ?: throw invalid("$key must be a string")
|
||||
|
||||
private fun JSONObject.optionalString(key: String): String? = when (val v = opt(key)) {
|
||||
null, JSONObject.NULL -> null
|
||||
is String -> v.takeIf { it.isNotBlank() }
|
||||
else -> throw invalid("$key must be a string")
|
||||
}
|
||||
|
||||
private fun JSONObject.target(table: ProviderTable, where: String): RowTarget {
|
||||
val id = when (val raw = opt("id")) {
|
||||
null, JSONObject.NULL -> null
|
||||
else -> (scalar(raw, "$where: id") as? Cell.Integer)?.value?.takeIf { it >= 0 }
|
||||
?: throw invalid("$where: id must be a row id")
|
||||
}
|
||||
if (id != null && table.idColumn == null) throw invalid("$where: ${table.wire} rows have no id")
|
||||
val (sql, args) = whereArgs(where)
|
||||
return RowTarget(id, sql, args)
|
||||
}
|
||||
|
||||
private fun JSONObject.whereArgs(where: String): Pair<String?, List<String>> {
|
||||
val sql = optionalString("where")?.also { SqlFragments.check(it, "$where: where") }
|
||||
val args = when (val raw = opt("args")) {
|
||||
null, JSONObject.NULL -> emptyList()
|
||||
is JSONArray -> (0 until raw.length()).map { i ->
|
||||
scalar(raw.opt(i), "$where: args[$i]").text()
|
||||
?: throw invalid("$where: args[$i] must be a string or a number")
|
||||
}
|
||||
else -> throw invalid("$where: args must be an array")
|
||||
}
|
||||
if (sql == null && args.isNotEmpty()) throw invalid("$where: args without a where")
|
||||
return sql to args
|
||||
}
|
||||
|
||||
private fun JSONObject.expectCount(where: String): Int? = when (val raw = opt("expectCount")) {
|
||||
null, JSONObject.NULL -> null
|
||||
else -> (scalar(raw, "$where: expectCount") as? Cell.Integer)?.value
|
||||
?.takeIf { it in 0..Int.MAX_VALUE }?.toInt()
|
||||
?: throw invalid("$where: expectCount must be a count")
|
||||
}
|
||||
|
||||
private fun JSONObject.refs(where: String): Map<String, Int> {
|
||||
val raw = opt("refs") ?: return emptyMap()
|
||||
if (raw == JSONObject.NULL) return emptyMap()
|
||||
if (raw !is JSONObject) throw invalid("$where: refs must be an object")
|
||||
val out = LinkedHashMap<String, Int>()
|
||||
for (column in raw.keys()) {
|
||||
val index = (scalar(raw.opt(column), "$where: refs.$column") as? Cell.Integer)?.value
|
||||
?.takeIf { it in 0..Int.MAX_VALUE }
|
||||
?: throw invalid("$where: refs.$column must be an op index")
|
||||
out[column] = index.toInt()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun JSONObject.writeValues(table: ProviderTable, where: String): Map<String, Cell> {
|
||||
val raw = opt("values") as? JSONObject ?: throw invalid("$where: values must be an object")
|
||||
val out = LinkedHashMap<String, Cell>()
|
||||
for (column in raw.keys()) {
|
||||
val v = raw.opt(column)
|
||||
out[column] = if (v is JSONObject) blob(table, column, v, where) else scalar(v, "$where: $column")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun JSONObject.assertValues(where: String): Map<String, Cell>? {
|
||||
val raw = opt("values") ?: return null
|
||||
if (raw == JSONObject.NULL) return null
|
||||
if (raw !is JSONObject) throw invalid("$where: values must be an object")
|
||||
val out = LinkedHashMap<String, Cell>()
|
||||
for (column in raw.keys()) out[column] = scalar(raw.opt(column), "$where: $column")
|
||||
return out
|
||||
}
|
||||
|
||||
/** Only photo rows carry bytes (`data.data15`); the provider stores and scales them itself. */
|
||||
private fun blob(table: ProviderTable, column: String, v: JSONObject, where: String): Cell.Blob {
|
||||
if (table != ProviderTable.DATA || column != "data15") {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "$where: bytes are only accepted for data.data15, not ${table.wire}.$column")
|
||||
}
|
||||
val base64 = v.opt("b64") as? String ?: throw invalid("$where: $column needs { b64 }")
|
||||
return Cell.Blob(base64)
|
||||
}
|
||||
|
||||
private fun scalar(v: Any?, what: String): Cell = when (v) {
|
||||
null, JSONObject.NULL -> Cell.Null
|
||||
is String -> Cell.Text(v)
|
||||
is Boolean -> Cell.Integer(if (v) 1 else 0)
|
||||
is Number -> numberCell(v)
|
||||
else -> throw invalid("$what must be text, a number or null")
|
||||
}
|
||||
|
||||
/** org.json yields Integer, Long, Double, BigInteger or BigDecimal depending on the platform and the value. */
|
||||
internal fun numberCell(n: Number): Cell = when (n) {
|
||||
is Int, is Long, is Short, is Byte -> Cell.Integer(n.toLong())
|
||||
is BigInteger -> if (n.bitLength() < 64) Cell.Integer(n.toLong()) else Cell.Real(n.toDouble())
|
||||
is BigDecimal -> try {
|
||||
Cell.Integer(n.longValueExact())
|
||||
} catch (e: ArithmeticException) {
|
||||
Cell.Real(n.toDouble())
|
||||
}
|
||||
else -> {
|
||||
val d = n.toDouble()
|
||||
if (d.isFinite() && d == Math.rint(d) && abs(d) <= MAX_SAFE_INTEGER) Cell.Integer(d.toLong()) else Cell.Real(d)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Plans provider calls for one account: every selection gets the account
|
||||
* condition ANDed in, and every row an operation names by id, or points to
|
||||
* from a written column, is checked to belong to the account first
|
||||
* (docs/device-sync.md, "Native module API", Scoping).
|
||||
*
|
||||
* How a table is tied to the account ([TableScope]):
|
||||
* - raw contacts, groups, settings, calendars and colors carry it in their
|
||||
* `account_name`/`account_type` columns;
|
||||
* - contacts data rows are filtered by ContactsProvider itself through the
|
||||
* `account_name`/`account_type` URI parameters. Their account columns are
|
||||
* never selected on: Android 17 drops them from the Data view for apps
|
||||
* targeting more than API 36;
|
||||
* - events get `calendar_id IN (…)` of the account's calendars, not the
|
||||
* account columns: CalendarProvider updates events on the Events table,
|
||||
* which has none;
|
||||
* - attendees, reminders and extended properties get `event_id IN (…)` of the
|
||||
* account's events among those the operation touches. CalendarProvider
|
||||
* applies no account filter to them.
|
||||
*
|
||||
* Selections never use subqueries: the providers may compile them strictly.
|
||||
*/
|
||||
class ScopePlanner(private val account: AccountRef, private val lookup: ScopeLookup) {
|
||||
private val calendarIds: Set<Long> by lazy { lookup.ownCalendarIds() }
|
||||
|
||||
fun planQuery(query: ProviderQuery): PlannedQuery {
|
||||
val target = RowTarget(null, query.where, query.args)
|
||||
val selections = if (query.table.scope == TableScope.EVENT_IDS) {
|
||||
// Mostly a handful of events (the engine reads children per chunk
|
||||
// of events); a query over all of them is split so no selection
|
||||
// outgrows a Binder transaction. Rows are then ordered per chunk.
|
||||
ownEventsMatching(query.table, target)
|
||||
.sorted()
|
||||
.chunked(ID_LIST_CHUNK)
|
||||
.map { chunk -> selection(query.table, target, chunk) }
|
||||
} else {
|
||||
listOf(selection(query.table, target, null))
|
||||
}
|
||||
return PlannedQuery(query.table, query.columns, selections, query.orderBy)
|
||||
}
|
||||
|
||||
fun planBatch(ops: List<ProviderOp>): List<PlannedOp> {
|
||||
val memberships = membershipUpdates(ops)
|
||||
val owners = Owners(ownershipRequests(ops, memberships))
|
||||
return ops.mapIndexed { i, op -> plan(i, op, owners, memberships) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Membership rows among the data rows that updates by id write `data1`
|
||||
* of without restating the mimetype: their `data1` names a group, which
|
||||
* must be the account's like on insert.
|
||||
*/
|
||||
private fun membershipUpdates(ops: List<ProviderOp>): Set<Long> {
|
||||
val ids = ops.asSequence()
|
||||
.filterIsInstance<ProviderOp.Update>()
|
||||
.filter { it.table == ProviderTable.DATA && writesUntypedData1(it.values) }
|
||||
.mapNotNull { it.target.id }
|
||||
.toSet()
|
||||
if (ids.isEmpty()) return emptySet()
|
||||
return lookup.dataMimetypes(ids).filterValues { it == GROUP_MEMBERSHIP_MIMETYPE }.keys
|
||||
}
|
||||
|
||||
private fun writesUntypedData1(values: Map<String, Cell>) =
|
||||
MIMETYPE !in values && (values[DATA1] ?: Cell.Null) != Cell.Null
|
||||
|
||||
private fun plan(i: Int, op: ProviderOp, owners: Owners, memberships: Set<Long>): PlannedOp = when (op) {
|
||||
is ProviderOp.SetSyncState -> PlannedOp.SetSyncState(op.value)
|
||||
is ProviderOp.Insert -> {
|
||||
checkAccountColumns(i, op.table, op.values)
|
||||
checkPhotoFile(i, op.table, op.values)
|
||||
checkReferences(i, op.table, op.values - op.refs.keys, owners)
|
||||
val values = if (op.table.scope == TableScope.ACCOUNT_COLUMNS) op.values + accountCells() else op.values
|
||||
PlannedOp.Insert(op.table, values, op.refs, op.yieldAllowed)
|
||||
}
|
||||
is ProviderOp.Update -> {
|
||||
checkAccountColumns(i, op.table, op.values)
|
||||
checkPhotoFile(i, op.table, op.values)
|
||||
checkReferences(i, op.table, op.values, owners)
|
||||
if (op.table == ProviderTable.DATA && writesUntypedData1(op.values)) {
|
||||
checkMembershipUpdate(i, op, owners, memberships)
|
||||
}
|
||||
checkTarget(i, op.table, op.target, owners)
|
||||
if (op.table == ProviderTable.EXTENDED_PROPERTIES) {
|
||||
// CalendarProvider updates extended properties only through
|
||||
// their item URI, which takes no selection: the id check above
|
||||
// is the scope.
|
||||
val id = op.target.id
|
||||
if (id == null || op.target.where != null) {
|
||||
throw ProviderRefusal(BatchFailure.PROVIDER, "op $i: extended properties can only be updated by id")
|
||||
}
|
||||
PlannedOp.Update(op.table, Selection.NONE, id, op.values, op.expectCount ?: 1, op.yieldAllowed)
|
||||
} else {
|
||||
val selection = selection(op.table, op.target, eventScope(op.table, op.target, owners))
|
||||
PlannedOp.Update(op.table, selection, null, op.values, expectedCount(op.target, op.expectCount), op.yieldAllowed)
|
||||
}
|
||||
}
|
||||
is ProviderOp.Delete -> {
|
||||
checkTarget(i, op.table, op.target, owners)
|
||||
val selection = selection(op.table, op.target, eventScope(op.table, op.target, owners))
|
||||
PlannedOp.Delete(op.table, selection, expectedCount(op.target, op.expectCount), op.yieldAllowed)
|
||||
}
|
||||
is ProviderOp.Assert -> {
|
||||
checkTarget(i, op.table, op.target, owners)
|
||||
val selection = selection(op.table, op.target, eventScope(op.table, op.target, owners))
|
||||
val values = op.values?.mapValues { (_, cell) -> cell.text() }
|
||||
PlannedOp.Assert(op.table, selection, values, expectedCount(op.target, op.expectCount), op.yieldAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
/** An op that names one row by id expects exactly that row: the engine relies on it to notice a vanished row. */
|
||||
private fun expectedCount(target: RowTarget, expectCount: Int?): Int? =
|
||||
expectCount ?: if (target.id != null) 1 else null
|
||||
|
||||
private fun accountCells() = mapOf(
|
||||
ACCOUNT_NAME to Cell.Text(account.name),
|
||||
ACCOUNT_TYPE to Cell.Text(account.type),
|
||||
)
|
||||
|
||||
/**
|
||||
* Account columns may only restate the account, on the tables that have
|
||||
* them; a data row, event or attendee follows its parent's account.
|
||||
*/
|
||||
private fun checkAccountColumns(i: Int, table: ProviderTable, values: Map<String, Cell>) {
|
||||
for (column in listOf(ACCOUNT_NAME, ACCOUNT_TYPE, DATA_SET)) {
|
||||
val cell = values[column] ?: continue
|
||||
val restatesAccount = table.scope == TableScope.ACCOUNT_COLUMNS && when (column) {
|
||||
ACCOUNT_NAME -> cell == Cell.Text(account.name)
|
||||
ACCOUNT_TYPE -> cell == Cell.Text(account.type)
|
||||
else -> cell == Cell.Null
|
||||
}
|
||||
if (!restatesAccount) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "op $i: ${table.wire}.$column would leave the account")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parents, masters and groups a written column names must be rows of the
|
||||
* account that still exist. (Back-references were checked by the parser:
|
||||
* they name inserts of the same batch.)
|
||||
*/
|
||||
private fun checkReferences(i: Int, table: ProviderTable, values: Map<String, Cell>, owners: Owners) {
|
||||
for ((column, cell) in values) {
|
||||
val referenced = referencedTable(table, column, values) ?: continue
|
||||
if (cell == Cell.Null) {
|
||||
if (column == table.parentColumn) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "op $i: ${table.wire}.$column must name a ${referenced.wire} row")
|
||||
}
|
||||
continue
|
||||
}
|
||||
val id = cell.rowId()
|
||||
?: throw ProviderRefusal(BatchFailure.PROVIDER, "op $i: ${table.wire}.$column must be a row id")
|
||||
when (owners.of(referenced, id)) {
|
||||
Owner.OURS -> Unit
|
||||
Owner.FOREIGN -> throw ProviderRefusal(
|
||||
BatchFailure.SCOPE,
|
||||
"op $i: ${table.wire}.$column names ${referenced.wire} $id of another account",
|
||||
)
|
||||
// Deleted meanwhile, as far as the engine can tell: re-read and re-plan.
|
||||
Owner.MISSING -> throw ProviderRefusal(
|
||||
BatchFailure.ASSERT,
|
||||
"op $i: ${table.wire}.$column names ${referenced.wire} $id, which no longer exists",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `data1` written by an update that doesn't restate the mimetype: on a
|
||||
* membership row it names a group. Rows chosen by a selection could be
|
||||
* memberships the lookup didn't see, so only updates by id may do it.
|
||||
*/
|
||||
private fun checkMembershipUpdate(i: Int, op: ProviderOp.Update, owners: Owners, memberships: Set<Long>) {
|
||||
val id = op.target.id
|
||||
?: throw ProviderRefusal(BatchFailure.SCOPE, "op $i: data1 may only be written to a data row by id")
|
||||
if (id !in memberships) return
|
||||
val group = op.values[DATA1]?.rowId()
|
||||
?: throw ProviderRefusal(BatchFailure.PROVIDER, "op $i: a membership's data1 must be a group id")
|
||||
when (owners.of(ProviderTable.GROUPS, group)) {
|
||||
Owner.OURS -> Unit
|
||||
Owner.FOREIGN -> throw ProviderRefusal(BatchFailure.SCOPE, "op $i: data.data1 names group $group of another account")
|
||||
Owner.MISSING -> throw ProviderRefusal(BatchFailure.ASSERT, "op $i: data.data1 names group $group, which no longer exists")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A photo row's file id (`data14`) is the provider's: it points into the
|
||||
* photo store, where another account's file id would let `readPhoto`
|
||||
* read that account's photo. Photos are written as bytes (`data15`).
|
||||
*/
|
||||
private fun checkPhotoFile(i: Int, table: ProviderTable, values: Map<String, Cell>) {
|
||||
if (table == ProviderTable.DATA && (values[DATA14] ?: Cell.Null) != Cell.Null) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "op $i: data.data14 (a photo file id) is written by the provider only")
|
||||
}
|
||||
}
|
||||
|
||||
/** A row named by id must not belong to another account; a missing one fails on the expected count instead. */
|
||||
private fun checkTarget(i: Int, table: ProviderTable, target: RowTarget, owners: Owners) {
|
||||
val id = target.id ?: return
|
||||
if (owners.of(table, id) == Owner.FOREIGN) {
|
||||
throw ProviderRefusal(BatchFailure.SCOPE, "op $i: ${table.wire} $id belongs to another account")
|
||||
}
|
||||
}
|
||||
|
||||
/** For attendees, reminders and extended properties: the account's events the op may touch. */
|
||||
private fun eventScope(table: ProviderTable, target: RowTarget, owners: Owners): Collection<Long>? {
|
||||
if (table.scope != TableScope.EVENT_IDS) return null
|
||||
val id = target.id ?: return ownEventsMatching(table, target)
|
||||
return listOfNotNull(owners.eventOf(table, id))
|
||||
}
|
||||
|
||||
private fun ownEventsMatching(table: ProviderTable, target: RowTarget): Set<Long> {
|
||||
val candidates = lookup.childEventIdsMatching(table, target.where, target.args)
|
||||
if (candidates.isEmpty()) return emptySet()
|
||||
return lookup.eventCalendars(candidates).filterValues { it in calendarIds }.keys
|
||||
}
|
||||
|
||||
private fun selection(table: ProviderTable, target: RowTarget, eventIds: Collection<Long>?): Selection {
|
||||
val parts = ArrayList<String>(3)
|
||||
val args = ArrayList<String>()
|
||||
target.id?.let {
|
||||
parts += "${table.idColumn} = ?"
|
||||
args += it.toString()
|
||||
}
|
||||
target.where?.let {
|
||||
parts += it
|
||||
args += target.args
|
||||
}
|
||||
when (table.scope) {
|
||||
TableScope.ACCOUNT_COLUMNS -> {
|
||||
parts += "$ACCOUNT_NAME = ? AND $ACCOUNT_TYPE = ?"
|
||||
args += account.name
|
||||
args += account.type
|
||||
}
|
||||
TableScope.URI_PARAMETERS -> Unit
|
||||
TableScope.CALENDAR_IDS -> parts += idList("calendar_id", calendarIds)
|
||||
TableScope.EVENT_IDS -> parts += idList("event_id", eventIds.orEmpty())
|
||||
}
|
||||
return Selection(parts.takeIf { it.isNotEmpty() }?.joinToString(" AND ") { "($it)" }, args)
|
||||
}
|
||||
|
||||
/** Every row id the batch names, by table: op targets and referenced rows. */
|
||||
private fun ownershipRequests(ops: List<ProviderOp>, memberships: Set<Long>): Map<ProviderTable, Set<Long>> {
|
||||
val requests = HashMap<ProviderTable, MutableSet<Long>>()
|
||||
fun request(table: ProviderTable, id: Long) {
|
||||
requests.getOrPut(table) { HashSet() } += id
|
||||
}
|
||||
fun requestReferences(table: ProviderTable, values: Map<String, Cell>) {
|
||||
for ((column, cell) in values) {
|
||||
val referenced = referencedTable(table, column, values) ?: continue
|
||||
cell.rowId()?.let { request(referenced, it) }
|
||||
}
|
||||
}
|
||||
for (op in ops) {
|
||||
when (op) {
|
||||
is ProviderOp.Insert -> requestReferences(op.table, op.values)
|
||||
is ProviderOp.Update -> {
|
||||
requestReferences(op.table, op.values)
|
||||
op.target.id?.let { request(op.table, it) }
|
||||
if (op.target.id in memberships) op.values[DATA1]?.rowId()?.let { request(ProviderTable.GROUPS, it) }
|
||||
}
|
||||
is ProviderOp.Delete -> op.target.id?.let { request(op.table, it) }
|
||||
is ProviderOp.Assert -> op.target.id?.let { request(op.table, it) }
|
||||
is ProviderOp.SetSyncState -> Unit
|
||||
}
|
||||
}
|
||||
return requests
|
||||
}
|
||||
|
||||
private enum class Owner { OURS, FOREIGN, MISSING }
|
||||
|
||||
/** Who owns the rows a batch names, resolved up front with as few lookups as the tables allow. */
|
||||
private inner class Owners(requests: Map<ProviderTable, Set<Long>>) {
|
||||
private val owners = HashMap<ProviderTable, Map<Long, Owner>>()
|
||||
private val childEvents = HashMap<ProviderTable, Map<Long, Long>>()
|
||||
|
||||
init {
|
||||
// Children lead to events, data rows to raw contacts; both are
|
||||
// added to their parent's lookup.
|
||||
val eventIds = HashSet(requests[ProviderTable.EVENTS].orEmpty())
|
||||
for (table in CHILD_TABLES) {
|
||||
val ids = requests[table] ?: continue
|
||||
val events = lookup.childEvents(table, ids)
|
||||
childEvents[table] = events
|
||||
eventIds += events.values
|
||||
}
|
||||
val rawContactIds = HashSet(requests[ProviderTable.RAW_CONTACTS].orEmpty())
|
||||
val dataParents = requests[ProviderTable.DATA]?.let { lookup.dataRawContacts(it) }.orEmpty()
|
||||
rawContactIds += dataParents.values
|
||||
|
||||
if (eventIds.isNotEmpty()) {
|
||||
owners[ProviderTable.EVENTS] = lookup.eventCalendars(eventIds)
|
||||
.mapValues { (_, calendarId) -> if (calendarId in calendarIds) Owner.OURS else Owner.FOREIGN }
|
||||
}
|
||||
for ((table, events) in childEvents) {
|
||||
// A child whose event is gone is an orphan, not ours.
|
||||
owners[table] = events.mapValues { (_, eventId) -> ownerOrForeign(ProviderTable.EVENTS, eventId) }
|
||||
}
|
||||
val roots = HashMap<ProviderTable, Set<Long>>()
|
||||
for (table in ROOT_TABLES) requests[table]?.let { roots[table] = it }
|
||||
if (rawContactIds.isNotEmpty()) roots[ProviderTable.RAW_CONTACTS] = rawContactIds
|
||||
for ((table, ids) in roots) {
|
||||
owners[table] = lookup.rootAccounts(table, ids)
|
||||
.mapValues { (_, owner) -> if (owner == account) Owner.OURS else Owner.FOREIGN }
|
||||
}
|
||||
if (dataParents.isNotEmpty()) {
|
||||
owners[ProviderTable.DATA] = dataParents.mapValues { (_, rawContactId) ->
|
||||
ownerOrForeign(ProviderTable.RAW_CONTACTS, rawContactId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ownerOrForeign(table: ProviderTable, id: Long): Owner =
|
||||
owners[table]?.get(id)?.takeIf { it != Owner.MISSING } ?: Owner.FOREIGN
|
||||
|
||||
fun of(table: ProviderTable, id: Long): Owner = owners[table]?.get(id) ?: Owner.MISSING
|
||||
|
||||
/** The event of a child row, when both exist and are the account's. */
|
||||
fun eventOf(table: ProviderTable, id: Long): Long? =
|
||||
childEvents[table]?.get(id)?.takeIf { of(table, id) == Owner.OURS }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACCOUNT_NAME = "account_name"
|
||||
const val ACCOUNT_TYPE = "account_type"
|
||||
const val DATA_SET = "data_set"
|
||||
private const val MIMETYPE = "mimetype"
|
||||
private const val DATA1 = "data1"
|
||||
private const val DATA14 = "data14"
|
||||
|
||||
/** Ids per `IN (…)` list: a few KB of selection, far from SQLite's and Binder's limits. */
|
||||
const val ID_LIST_CHUNK = 500
|
||||
|
||||
private val CHILD_TABLES = ProviderTable.entries.filter { it.scope == TableScope.EVENT_IDS }
|
||||
private val ROOT_TABLES = ProviderTable.entries.filter { it.scope == TableScope.ACCOUNT_COLUMNS }
|
||||
|
||||
/** `column IN (1,2,3)`, or a condition no row meets. The ids are ours to inline: numbers we read. */
|
||||
fun idList(column: String, ids: Collection<Long>): String =
|
||||
if (ids.isEmpty()) "0" else ids.sorted().joinToString(",", "$column IN (", ")")
|
||||
}
|
||||
}
|
||||
|
||||
/** An Android account of our type: the scope of every provider call. */
|
||||
data class AccountRef(val name: String, val type: String)
|
||||
|
||||
/**
|
||||
* What [ScopePlanner] needs to know about rows beyond the operations
|
||||
* themselves. [ProviderIo] answers with provider queries: except for
|
||||
* [ownCalendarIds], these read rows of any account, but only their ids and
|
||||
* owners, to tell rows of another account from rows that are gone.
|
||||
*/
|
||||
interface ScopeLookup {
|
||||
/** `_id`s of the account's calendars. */
|
||||
fun ownCalendarIds(): Set<Long>
|
||||
|
||||
/** The account of each row of a root table that exists. */
|
||||
fun rootAccounts(table: ProviderTable, ids: Set<Long>): Map<Long, AccountRef>
|
||||
|
||||
/** The raw contact of each data row that exists. */
|
||||
fun dataRawContacts(ids: Set<Long>): Map<Long, Long>
|
||||
|
||||
/** The mimetype of each data row that exists. */
|
||||
fun dataMimetypes(ids: Set<Long>): Map<Long, String>
|
||||
|
||||
/** The calendar of each event that exists. */
|
||||
fun eventCalendars(ids: Set<Long>): Map<Long, Long>
|
||||
|
||||
/** The event of each attendee, reminder or extended property that exists. */
|
||||
fun childEvents(table: ProviderTable, ids: Set<Long>): Map<Long, Long>
|
||||
|
||||
/** The events of the rows of a child table that match [where]. */
|
||||
fun childEventIdsMatching(table: ProviderTable, where: String?, args: List<String>): Set<Long>
|
||||
}
|
||||
|
||||
/** A selection with its `?` arguments; a null [sql] selects every row the URI allows. */
|
||||
data class Selection(val sql: String?, val args: List<String>) {
|
||||
companion object {
|
||||
val NONE = Selection(null, emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
/** A provider query, split into [selections] when its scope list is long; no selection means no rows. */
|
||||
data class PlannedQuery(
|
||||
val table: ProviderTable,
|
||||
val columns: List<String>,
|
||||
val selections: List<Selection>,
|
||||
val orderBy: String?,
|
||||
)
|
||||
|
||||
/** One ContentProviderOperation, ready to build on the account's sync-adapter URI. */
|
||||
sealed interface PlannedOp {
|
||||
val yieldAllowed: Boolean
|
||||
|
||||
data class Insert(
|
||||
val table: ProviderTable,
|
||||
val values: Map<String, Cell>,
|
||||
val backReferences: Map<String, Int>,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : PlannedOp
|
||||
|
||||
/** [itemId] set: an update through the row's own URI, without a selection. */
|
||||
data class Update(
|
||||
val table: ProviderTable,
|
||||
val selection: Selection,
|
||||
val itemId: Long?,
|
||||
val values: Map<String, Cell>,
|
||||
val expectedCount: Int?,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : PlannedOp
|
||||
|
||||
data class Delete(
|
||||
val table: ProviderTable,
|
||||
val selection: Selection,
|
||||
val expectedCount: Int?,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : PlannedOp
|
||||
|
||||
/** [values] compared as text, as ContentProviderOperation does. */
|
||||
data class Assert(
|
||||
val table: ProviderTable,
|
||||
val selection: Selection,
|
||||
val values: Map<String, String?>?,
|
||||
val expectedCount: Int?,
|
||||
override val yieldAllowed: Boolean,
|
||||
) : PlannedOp
|
||||
|
||||
data class SetSyncState(val value: String) : PlannedOp {
|
||||
override val yieldAllowed: Boolean get() = false
|
||||
}
|
||||
}
|
||||
|
||||
/** The result of one op, as `OpResult` of types.ts. */
|
||||
data class OpOutcome(val id: Long? = null, val count: Int? = null)
|
||||
|
||||
object ProviderResults {
|
||||
fun ok(results: List<OpOutcome>): String {
|
||||
val array = JSONArray()
|
||||
for (result in results) {
|
||||
array.put(JSONObject().apply {
|
||||
result.id?.let { put("id", it) }
|
||||
result.count?.let { put("count", it) }
|
||||
})
|
||||
}
|
||||
return JSONObject().put("ok", true).put("results", array).toString()
|
||||
}
|
||||
|
||||
fun failed(reason: BatchFailure, message: String?): String = JSONObject()
|
||||
.put("ok", false)
|
||||
.put("reason", reason.wire)
|
||||
.put("message", message ?: reason.wire)
|
||||
.toString()
|
||||
|
||||
/**
|
||||
* An OperationApplicationException is an assert that did not hold or an
|
||||
* expected count that did not match (`assert`), except when an insert got
|
||||
* no row back or ContactsProvider refused the batch's yield points.
|
||||
*/
|
||||
fun failureOfOperationApplication(message: String?): BatchFailure = when {
|
||||
message == null -> BatchFailure.ASSERT
|
||||
message.startsWith("Insert into ") -> BatchFailure.PROVIDER
|
||||
message.startsWith("Too many content provider operations") -> BatchFailure.PROVIDER
|
||||
else -> BatchFailure.ASSERT
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query results as `ProviderRows` JSON, built by hand: a chunk of contacts
|
||||
* can carry megabytes of shadows, and this is the hot path of every run.
|
||||
*/
|
||||
class ProviderRowsWriter(columns: List<String>) {
|
||||
private val out = StringBuilder(4096)
|
||||
private var rows = 0
|
||||
private var cells = 0
|
||||
|
||||
init {
|
||||
out.append("{\"columns\":[")
|
||||
columns.forEachIndexed { i, column ->
|
||||
if (i > 0) out.append(',')
|
||||
appendString(column)
|
||||
}
|
||||
out.append("],\"rows\":[")
|
||||
}
|
||||
|
||||
fun beginRow() {
|
||||
if (rows++ > 0) out.append(',')
|
||||
out.append('[')
|
||||
cells = 0
|
||||
}
|
||||
|
||||
fun endRow() {
|
||||
out.append(']')
|
||||
}
|
||||
|
||||
fun nullCell() = cell { out.append("null") }
|
||||
|
||||
fun longCell(value: Long) = cell { out.append(value) }
|
||||
|
||||
fun doubleCell(value: Double) = cell { if (value.isFinite()) out.append(value) else out.append("null") }
|
||||
|
||||
fun textCell(value: String) = cell { appendString(value) }
|
||||
|
||||
fun finish(): String = out.append("]}").toString()
|
||||
|
||||
private inline fun cell(write: () -> Unit) {
|
||||
if (cells++ > 0) out.append(',')
|
||||
write()
|
||||
}
|
||||
|
||||
private fun appendString(s: String) {
|
||||
out.append('"')
|
||||
for (c in s) {
|
||||
when {
|
||||
c == '"' -> out.append("\\\"")
|
||||
c == '\\' -> out.append("\\\\")
|
||||
c == '\n' -> out.append("\\n")
|
||||
c == '\r' -> out.append("\\r")
|
||||
c == '\t' -> out.append("\\t")
|
||||
c < ' ' || c.code == 0x2028 || c.code == 0x2029 ->
|
||||
out.append("\\u").append(Integer.toHexString(c.code).padStart(4, '0'))
|
||||
else -> out.append(c)
|
||||
}
|
||||
}
|
||||
out.append('"')
|
||||
}
|
||||
}
|
||||
|
||||
/** Sizes for [ProviderIo.readPhoto]: decode close to the target, then scale the longer side down to it. */
|
||||
object PhotoScaling {
|
||||
/** The largest power of two that keeps the decoded image at least [maxPx] on its longer side. */
|
||||
fun sampleSize(width: Int, height: Int, maxPx: Int): Int {
|
||||
require(maxPx > 0) { "maxPx must be positive" }
|
||||
var sample = 1
|
||||
val longer = maxOf(width, height)
|
||||
while (longer / (sample * 2) >= maxPx) sample *= 2
|
||||
return sample
|
||||
}
|
||||
|
||||
fun targetSize(width: Int, height: Int, maxPx: Int): Pair<Int, Int> {
|
||||
val longer = maxOf(width, height)
|
||||
if (longer <= maxPx) return width to height
|
||||
val scale = maxPx.toDouble() / longer
|
||||
return maxOf(1, Math.round(width * scale).toInt()) to maxOf(1, Math.round(height * scale).toInt())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* What a push asks for (#34): syncs of the Android accounts that contacts and
|
||||
* calendar changes feed, and whether the mail push task should run at all.
|
||||
*
|
||||
* The relay forwards the JMAP StateChange's `changed` map verbatim (FCM data
|
||||
* key `changed`: JMAP account id → type → state; only its first account is
|
||||
* repeated as `accountId`), so every account key is looked at. The mail task
|
||||
* runs only for mail: when some account's map has a mail type, for anything
|
||||
* but a StateChange (an EmailPush), when `changed` is missing, unreadable or
|
||||
* names no type (older relays), or when an account's entry is unreadable, so
|
||||
* a contact change never ends in a mail notification. The push task's guard
|
||||
* (`carriesNoMail` in src/lib/push-background-task.ts) applies the same rules.
|
||||
*/
|
||||
object PushRouting {
|
||||
/** An Android account and the authorities it syncs, fed by one JMAP account. */
|
||||
data class Route(val accountName: String, val authorities: Set<String>)
|
||||
|
||||
data class Decision(
|
||||
/** (Android account name, authority) pairs to request a sync for. */
|
||||
val syncs: Set<Pair<String, String>>,
|
||||
val startMailTask: Boolean,
|
||||
)
|
||||
|
||||
private const val STATE_CHANGE_KIND = "jmap-state-change"
|
||||
|
||||
/**
|
||||
* Types that may mean new mail: `EmailDelivery`, and `Email`/`Mailbox`,
|
||||
* which new mail changes too. A subscription made by an older build
|
||||
* listens to them until the launch-time resync replaces its types, and a
|
||||
* server without `EmailDelivery` has only them. Keep in sync with
|
||||
* MAIL_TYPES in src/lib/push-background-task.ts.
|
||||
*/
|
||||
private val MAIL_TYPES = setOf("EmailDelivery", "Email", "Mailbox")
|
||||
private val CONTACT_TYPES = setOf("ContactCard", "AddressBook")
|
||||
private val CALENDAR_TYPES = setOf("CalendarEvent", "Calendar")
|
||||
|
||||
fun decide(data: Map<String, String>, routes: Map<String, List<Route>>): Decision {
|
||||
val changed = data["changed"]?.let(::parseChanged)
|
||||
// Only a StateChange can say it carries no mail.
|
||||
var mail = data["kind"] != STATE_CHANGE_KIND || changed == null
|
||||
val syncs = LinkedHashSet<Pair<String, String>>()
|
||||
for ((jmapAccountId, types) in changed.orEmpty()) {
|
||||
if (types == null) {
|
||||
// One account's entry is unreadable: it may be mail.
|
||||
mail = true
|
||||
continue
|
||||
}
|
||||
if (types.any { it in MAIL_TYPES }) mail = true
|
||||
val contacts = types.any { it in CONTACT_TYPES }
|
||||
val calendar = types.any { it in CALENDAR_TYPES }
|
||||
for (route in routes[jmapAccountId].orEmpty()) {
|
||||
if (contacts && DeviceSyncAccounts.CONTACTS_AUTHORITY in route.authorities) {
|
||||
syncs += route.accountName to DeviceSyncAccounts.CONTACTS_AUTHORITY
|
||||
}
|
||||
if (calendar && DeviceSyncAccounts.CALENDAR_AUTHORITY in route.authorities) {
|
||||
syncs += route.accountName to DeviceSyncAccounts.CALENDAR_AUTHORITY
|
||||
}
|
||||
}
|
||||
}
|
||||
return Decision(syncs, mail)
|
||||
}
|
||||
|
||||
/**
|
||||
* JMAP account id → its changed types (null for an entry that is not an
|
||||
* object). Null for a map that is unreadable or names no type at all,
|
||||
* which the relay sends as `{}` when it had no map.
|
||||
*/
|
||||
private fun parseChanged(json: String): Map<String, Set<String>?>? {
|
||||
val o = try {
|
||||
JSONObject(json)
|
||||
} catch (e: JSONException) {
|
||||
return null
|
||||
}
|
||||
val out = LinkedHashMap<String, Set<String>?>()
|
||||
for (jmapAccountId in o.keys()) {
|
||||
val types = o.opt(jmapAccountId) as? JSONObject
|
||||
out[jmapAccountId] = types?.keys()?.asSequence()?.toSet()
|
||||
}
|
||||
return out.takeIf { entries -> entries.values.any { it == null || it.isNotEmpty() } }
|
||||
}
|
||||
|
||||
/**
|
||||
* `{ jmapAccountId: [{ accountName, authorities }] }` as JS stores it.
|
||||
* [strict] refuses anything malformed (IllegalArgumentException); otherwise
|
||||
* malformed entries are skipped, so a bad route never stops mail.
|
||||
*/
|
||||
fun parseRoutes(json: String?, strict: Boolean = false): Map<String, List<Route>> {
|
||||
if (json.isNullOrEmpty()) return emptyMap()
|
||||
fun bad(message: String): Nothing = throw IllegalArgumentException("Push routes: $message")
|
||||
val o = try {
|
||||
JSONObject(json)
|
||||
} catch (e: JSONException) {
|
||||
if (strict) bad("not a JSON object") else return emptyMap()
|
||||
}
|
||||
val out = LinkedHashMap<String, List<Route>>()
|
||||
for (jmapAccountId in o.keys()) {
|
||||
val entries = o.opt(jmapAccountId) as? JSONArray
|
||||
?: if (strict) bad("$jmapAccountId must map to an array") else continue
|
||||
val routes = ArrayList<Route>()
|
||||
for (i in 0 until entries.length()) {
|
||||
val entry = entries.opt(i) as? JSONObject
|
||||
val name = entry?.opt("accountName") as? String
|
||||
val authorities = entry?.opt("authorities") as? JSONArray
|
||||
if (name.isNullOrEmpty() || authorities == null) {
|
||||
if (strict) bad("$jmapAccountId[$i] needs accountName and authorities") else continue
|
||||
}
|
||||
val known = (0 until authorities.length()).mapNotNull { j ->
|
||||
val authority = authorities.opt(j) as? String
|
||||
if (authority !in DeviceSyncAccounts.AUTHORITIES) {
|
||||
if (strict) bad("$jmapAccountId[$i]: unknown authority $authority") else null
|
||||
} else {
|
||||
authority
|
||||
}
|
||||
}
|
||||
routes += Route(name, known.toSet())
|
||||
}
|
||||
out[jmapAccountId] = routes
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies [PushRouting] to incoming pushes (BulwarkMessagingService,
|
||||
* BulwarkUnifiedPushService) with the routes JS stored through
|
||||
* `setPushRoutes`.
|
||||
*/
|
||||
object DeviceSyncPushRouter {
|
||||
private const val TAG = "BulwarkDeviceSync"
|
||||
private const val PREFS = "bulwark_device_sync"
|
||||
private const val KEY_ROUTES = "pushRoutes"
|
||||
|
||||
/** Validates and stores the routes; they must survive the process, pushes arrive without JS. */
|
||||
fun saveRoutes(context: Context, json: String) {
|
||||
PushRouting.parseRoutes(json, strict = true)
|
||||
val saved = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putString(KEY_ROUTES, json)
|
||||
.commit()
|
||||
check(saved) { "Push routes could not be saved" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the syncs the push asks for, with constant (empty) extras so
|
||||
* repeated pushes coalesce into one pending sync. Returns whether the mail
|
||||
* push task should run; any failure here says yes, so mail never depends
|
||||
* on device sync.
|
||||
*/
|
||||
fun route(context: Context, data: Map<String, String>): Boolean = try {
|
||||
val routes = PushRouting.parseRoutes(
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_ROUTES, null),
|
||||
)
|
||||
val decision = PushRouting.decide(data, routes)
|
||||
for ((accountName, authority) in decision.syncs) {
|
||||
try {
|
||||
DeviceSyncAccounts.requestSync(context, accountName, authority, SyncRequestFlags())
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "Push: no sync requested for $authority", e)
|
||||
}
|
||||
}
|
||||
decision.startMailTask
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Push not routed", e)
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.anonymous.bulwarkmobile.MainActivity
|
||||
import com.anonymous.bulwarkmobile.R
|
||||
|
||||
/**
|
||||
* The one notification per Android account that device sync needs the user
|
||||
* for (#34), e.g. "Sign in again" after the server rejected the credentials.
|
||||
* The engine posts it through `showSyncProblem`; the app clears it once the
|
||||
* problem is solved. Tapping it opens a `bulwarkmobile://` deep link in the
|
||||
* app.
|
||||
*/
|
||||
object SyncProblemNotifier {
|
||||
const val CHANNEL_ID = "bulwark_sync"
|
||||
private const val DEFAULT_CHANNEL_NAME = "Sync problems"
|
||||
private const val TAG_PREFIX = "bulwark-sync:"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
private const val DEEP_LINK_SCHEME = "bulwarkmobile"
|
||||
|
||||
/**
|
||||
* Creates the channel, or renames it: JS passes the name in the app's
|
||||
* language. Nothing urgent, so default importance and no vibration.
|
||||
*/
|
||||
fun ensureChannel(context: Context, name: String?) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
name?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply { enableVibration(false) }
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
fun show(context: Context, accountName: String, title: String, text: String, uri: String, channelName: String?) {
|
||||
val link = Uri.parse(uri)
|
||||
require(link.scheme == DEEP_LINK_SCHEME) { "Only $DEEP_LINK_SCHEME:// links open from a sync notification" }
|
||||
ensureChannel(context, channelName)
|
||||
val tag = TAG_PREFIX + accountName
|
||||
// Explicit, so no other app can answer the tap.
|
||||
val intent = Intent(Intent.ACTION_VIEW, link, context, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
val pending = PendingIntent.getActivity(
|
||||
context,
|
||||
tag.hashCode(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
// Runs repeat the same problem until it is solved; say it once.
|
||||
.setOnlyAlertOnce(true)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pending)
|
||||
.build()
|
||||
context.getSystemService(NotificationManager::class.java).notify(tag, NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
fun clear(context: Context, accountName: String) {
|
||||
context.getSystemService(NotificationManager::class.java).cancel(TAG_PREFIX + accountName, NOTIFICATION_ID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.anonymous.bulwarkmobile.sync
|
||||
|
||||
import android.content.SyncResult
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* What a run's report (src/device-sync/types.ts `RunReport`) asks of the
|
||||
* framework's SyncResult. Kept free of Android classes so JVM tests cover it;
|
||||
* [SyncReports.apply] copies it onto the real SyncResult.
|
||||
*
|
||||
* SyncManager treats io errors as soft (retry with back-off), auth errors,
|
||||
* database errors and too many deletions as hard (no retry until something
|
||||
* changes), and `fullSyncRequested` as "schedule another sync now".
|
||||
*/
|
||||
data class SyncOutcome(
|
||||
val ioErrors: Long = 0,
|
||||
val authErrors: Long = 0,
|
||||
val databaseError: Boolean = false,
|
||||
val tooManyDeletions: Boolean = false,
|
||||
val inserts: Long = 0,
|
||||
val updates: Long = 0,
|
||||
val deletes: Long = 0,
|
||||
val entries: Long = 0,
|
||||
val skipped: Long = 0,
|
||||
/** Epoch seconds; 0 when unset. */
|
||||
val delayUntil: Long = 0,
|
||||
val fullSyncRequested: Boolean = false,
|
||||
)
|
||||
|
||||
object SyncReports {
|
||||
/**
|
||||
* Translates a report. [reportJson] is null when the run ended without
|
||||
* one ([endReason] says why: JS never answered, React failed to start, …),
|
||||
* which is a soft error so the framework retries.
|
||||
*/
|
||||
fun outcome(reportJson: String?, endReason: String?): SyncOutcome {
|
||||
if (reportJson == null) return SyncOutcome(ioErrors = 1)
|
||||
val report = try {
|
||||
JSONObject(reportJson)
|
||||
} catch (e: JSONException) {
|
||||
return SyncOutcome(ioErrors = 1)
|
||||
}
|
||||
val stats = report.optJSONObject("stats")
|
||||
val down = stats?.optJSONObject("downloaded")
|
||||
val up = stats?.optJSONObject("uploaded")
|
||||
fun count(side: JSONObject?, key: String) = side?.optLong(key, 0) ?: 0
|
||||
val base = SyncOutcome(
|
||||
inserts = count(down, "created") + count(up, "created"),
|
||||
updates = count(down, "updated") + count(up, "updated"),
|
||||
deletes = count(down, "deleted") + count(up, "deleted"),
|
||||
entries = stats?.optLong("entries", 0) ?: 0,
|
||||
skipped = stats?.optLong("skipped", 0) ?: 0,
|
||||
delayUntil = report.optLong("delayUntil", 0),
|
||||
fullSyncRequested = report.optBoolean("moreRecordsToGet", false),
|
||||
)
|
||||
// SyncManager retries a failed sync at once, without back-off, when it
|
||||
// made progress (inserts, updates, or deletes without
|
||||
// tooManyDeletions). A hard error must therefore report none, or an
|
||||
// auth failure after some work would loop.
|
||||
val hard = base.copy(inserts = 0, updates = 0, deletes = 0)
|
||||
return when (report.optString("outcome")) {
|
||||
"ok", "cancelled", "disabled" -> base
|
||||
"auth" -> hard.copy(authErrors = 1)
|
||||
"permission", "unsupported", "safetyAbort" -> hard.copy(databaseError = true)
|
||||
"tooManyDeletions" -> hard.copy(
|
||||
tooManyDeletions = true,
|
||||
// Shown in the system's "too many deletions" notification.
|
||||
deletes = report.optJSONObject("tooManyDeletions")?.optLong("count", 0) ?: 0,
|
||||
)
|
||||
// "io", "internal" and anything a newer JS might send: retry, at
|
||||
// once if some work got done, else with back-off.
|
||||
else -> base.copy(ioErrors = 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One line for logcat: the report's outcome, its message and how many
|
||||
* items failed. The reason a run failed is otherwise only in the report,
|
||||
* and JS warnings from a headless run don't reach logcat.
|
||||
*/
|
||||
fun summary(reportJson: String?): String {
|
||||
if (reportJson == null) return "no report"
|
||||
val report = try {
|
||||
JSONObject(reportJson)
|
||||
} catch (e: JSONException) {
|
||||
return "unreadable report"
|
||||
}
|
||||
val parts = mutableListOf("outcome=" + report.optString("outcome", "?"))
|
||||
val items = report.optJSONArray("itemErrors")?.length() ?: 0
|
||||
if (items > 0) parts += "itemErrors=$items"
|
||||
val message = report.optString("message", "")
|
||||
if (message.isNotEmpty()) parts += "message=" + message.take(MAX_LOGGED_MESSAGE)
|
||||
return parts.joinToString(" ")
|
||||
}
|
||||
|
||||
private const val MAX_LOGGED_MESSAGE = 300
|
||||
|
||||
fun apply(outcome: SyncOutcome, result: SyncResult) {
|
||||
result.stats.numIoExceptions += outcome.ioErrors
|
||||
result.stats.numAuthExceptions += outcome.authErrors
|
||||
result.stats.numInserts += outcome.inserts
|
||||
result.stats.numUpdates += outcome.updates
|
||||
result.stats.numDeletes += outcome.deletes
|
||||
result.stats.numEntries += outcome.entries
|
||||
result.stats.numSkippedEntries += outcome.skipped
|
||||
if (outcome.databaseError) result.databaseError = true
|
||||
if (outcome.tooManyDeletions) result.tooManyDeletions = true
|
||||
if (outcome.delayUntil > 0) result.delayUntil = outcome.delayUntil
|
||||
if (outcome.fullSyncRequested) result.fullSyncRequested = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Generated by scripts/sync-android-widgets.mjs from src/widgets/catalog.json.
|
||||
// react-native-android-widget names a widget after its provider class, so each
|
||||
// widget needs its own subclass; the JS side (src/widgets/task-handler.tsx)
|
||||
// renders by that name.
|
||||
package com.anonymous.bulwarkmobile.widget
|
||||
|
||||
import com.reactnativeandroidwidget.RNWidgetProvider
|
||||
|
||||
class InboxWidget : RNWidgetProvider()
|
||||
class TriageWidget : RNWidgetProvider()
|
||||
class UnreadCountWidget : RNWidgetProvider()
|
||||
class LatestMessageWidget : RNWidgetProvider()
|
||||
class FolderCountsWidget : RNWidgetProvider()
|
||||
class MailShortcutsWidget : RNWidgetProvider()
|
||||
class AllAccountsWidget : RNWidgetProvider()
|
||||
class StarredWidget : RNWidgetProvider()
|
||||
class FavouritePeopleWidget : RNWidgetProvider()
|
||||
class MailSearchWidget : RNWidgetProvider()
|
||||
class OutboxWidget : RNWidgetProvider()
|
||||
class TagWidget : RNWidgetProvider()
|
||||
class LastDraftWidget : RNWidgetProvider()
|
||||
class AgendaWidget : RNWidgetProvider()
|
||||
class WeekWidget : RNWidgetProvider()
|
||||
class MonthWidget : RNWidgetProvider()
|
||||
class TodayEventsWidget : RNWidgetProvider()
|
||||
class NextEventWidget : RNWidgetProvider()
|
||||
class DateNextWidget : RNWidgetProvider()
|
||||
class UpNextWidget : RNWidgetProvider()
|
||||
class InvitationsWidget : RNWidgetProvider()
|
||||
class FreeTimeWidget : RNWidgetProvider()
|
||||
class TasksWidget : RNWidgetProvider()
|
||||
class TaskProgressWidget : RNWidgetProvider()
|
||||
class CountdownWidget : RNWidgetProvider()
|
||||
class BirthdaysWidget : RNWidgetProvider()
|
||||
class TodayHubWidget : RNWidgetProvider()
|
||||
class MailAndNextWidget : RNWidgetProvider()
|
||||
class RecentFilesWidget : RNWidgetProvider()
|
||||
class AttachmentsWidget : RNWidgetProvider()
|
||||
class VacationWidget : RNWidgetProvider()
|
||||
class StorageWidget : RNWidgetProvider()
|
||||
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Status-bar icon for mail notifications: the Bulwark shield from
|
||||
assets/logos/Bulwark Logo White.svg, white on transparent. Android draws
|
||||
only the alpha channel of a small icon; the group scale keeps the shield
|
||||
inside the 22dp live area of the 24dp canvas. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="1000"
|
||||
android:viewportHeight="1000">
|
||||
<group
|
||||
android:pivotX="500"
|
||||
android:pivotY="500"
|
||||
android:scaleX="0.9"
|
||||
android:scaleY="0.9">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M 373.467 557.247 L 55.226 271.52 L 55.226 385.564 C 55.226 398.153 62.453 415.6 71.355 424.501 L 107.525 460.671 C 116.426 469.573 123.653 487.02 123.653 499.609 L 123.653 682.08 C 123.655 718.462 133.336 750.017 152.047 778.668 L 373.467 557.247 Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M 626.533 557.247 L 944.774 271.52 L 944.774 396.969 C 944.774 403.263 941.16 411.987 936.709 416.437 L 884.411 468.736 C 879.96 473.186 876.347 481.91 876.347 488.204 L 876.347 682.08 C 876.345 718.462 866.664 750.017 847.953 778.668 L 626.533 557.247 Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M 500 638.743 L 500 638.743 C 466.206 638.738 448.855 625.316 424.448 603.033 L 197.809 829.672 C 261.382 884.635 364.213 931.859 500 990 Q 500 990 500 990 C 635.787 931.859 738.618 884.635 802.191 829.672 L 575.552 603.033 C 551.145 625.316 533.794 638.738 500 638.743 Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M 483.028 563.647 L 63.713 187.183 C 59.029 182.978 55.226 174.454 55.226 168.159 L 55.226 122.542 C 55.226 116.247 60.206 109.988 66.339 108.573 L 215.181 74.225 C 221.314 72.809 226.293 76.77 226.293 83.065 L 226.293 176.92 L 352.034 147.895 C 358.167 146.479 363.147 140.219 363.147 133.925 L 363.147 51.483 C 363.147 45.189 368.126 38.93 374.259 37.514 L 488.888 11.061 C 495.021 9.646 504.979 9.646 511.112 11.061 L 625.741 37.514 C 631.874 38.93 636.853 45.189 636.853 51.483 L 636.853 133.925 C 636.853 140.219 641.833 146.479 647.966 147.895 L 773.707 176.92 L 773.707 83.065 C 773.707 76.77 778.686 72.809 784.819 74.225 L 933.661 108.573 C 939.794 109.988 944.774 116.247 944.774 122.542 L 944.774 168.159 C 944.774 174.454 940.971 182.978 936.287 187.183 L 516.972 563.647 C 507.605 572.056 492.395 572.056 483.028 563.647 Z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Home-screen widget edge, drawn over the widget by layout/rn_widget.xml. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="@color/widget_frame" />
|
||||
<corners android:radius="@dimen/widget_radius" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Home-screen widget root (layout/rn_widget.xml): its rounded outline is what the widget clips to. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/widget_surface" />
|
||||
<corners android:radius="@dimen/widget_radius" />
|
||||
</shape>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Night twin of layout/rn_widget.xml, which explains the override. -->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@android:id/background"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/widget_surface"
|
||||
android:clipToOutline="true"
|
||||
android:foreground="@drawable/widget_frame"
|
||||
tools:targetApi="31">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/rn_widget_image_light"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/transparent"
|
||||
android:scaleType="matrix"
|
||||
android:visibility="gone" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/rn_widget_image_dark"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/transparent"
|
||||
android:scaleType="matrix"
|
||||
android:visibility="visible" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/rn_widget_clickable_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/rn_widget_collection_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Overrides react-native-android-widget's rn_widget.xml (same ids and children;
|
||||
layout-night/rn_widget.xml is its night twin).
|
||||
|
||||
The library draws each widget into one bitmap sized from the size the
|
||||
launcher reports, and pins it to the top left without scaling. When the real
|
||||
frame is smaller (after a rotation, or on a launcher that over-reports), the
|
||||
bitmap's bottom edge is cut off and takes the rounded corners and the border
|
||||
with it, so the bottom corners fall back to whatever the launcher clips to.
|
||||
This root always has the real size, so it owns the shape instead: it clips to
|
||||
a rounded outline (Android 12+; the launcher then leaves the corners to us),
|
||||
fills in where the bitmap falls short, and draws the frame on top.
|
||||
-->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@android:id/background"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/widget_surface"
|
||||
android:clipToOutline="true"
|
||||
android:foreground="@drawable/widget_frame"
|
||||
tools:targetApi="31">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/rn_widget_image_light"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/transparent"
|
||||
android:scaleType="matrix"
|
||||
android:visibility="visible" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/rn_widget_image_dark"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/transparent"
|
||||
android:scaleType="matrix"
|
||||
android:visibility="gone" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/rn_widget_clickable_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/rn_widget_collection_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</FrameLayout>
|
||||
|
Before Width: | Height: | Size: 482 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 482 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 350 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 350 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 634 B After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 634 B After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 964 B After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 964 B After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 6.5 KiB |
@@ -1 +1,8 @@
|
||||
<resources/>
|
||||
<resources>
|
||||
<!-- As `splash.dark.backgroundColor` in app.config.js. -->
|
||||
<color name="splashscreen_background">#09090b</color>
|
||||
<!-- Matches the dark palette background (src/theme/tokens.ts). -->
|
||||
<color name="window_background">#09090b</color>
|
||||
<!-- DARK.bg in src/widgets/theme.ts. -->
|
||||
<color name="widget_surface">#0a0a0a</color>
|
||||
</resources>
|
||||
@@ -1,6 +1,13 @@
|
||||
<resources>
|
||||
<color name="splashscreen_background">#09090b</color>
|
||||
<color name="iconBackground">#09090b</color>
|
||||
<!-- Splash background, as `splash.backgroundColor` in app.config.js; the dark one is in values-night. -->
|
||||
<color name="splashscreen_background">#ffffff</color>
|
||||
<color name="iconBackground">#db2d54</color>
|
||||
<color name="colorPrimary">#023c69</color>
|
||||
<color name="colorPrimaryDark">#09090b</color>
|
||||
<!-- Behind the React root until the first frame; matches the light palette background (src/theme/tokens.ts). -->
|
||||
<color name="window_background">#ffffff</color>
|
||||
<!-- Home-screen widget surface (LIGHT.bg in src/widgets/theme.ts); it only shows where the drawn widget falls short of the launcher's frame. -->
|
||||
<color name="widget_surface">#ffffff</color>
|
||||
<!-- Home-screen widget edge, one colour for both schemes: the app can force a theme the launcher's night mode doesn't know about. -->
|
||||
<color name="widget_frame">#4d808080</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<resources>
|
||||
<!-- Home-screen widget corners; WIDGET_RADIUS in src/widgets/primitives.tsx. -->
|
||||
<dimen name="widget_radius">16dp</dimen>
|
||||
</resources>
|
||||
@@ -2,4 +2,6 @@
|
||||
<string name="app_name">Bulwark Mobile</string>
|
||||
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
|
||||
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
|
||||
<!-- Account type label of device sync (#34), shown in Settings → Accounts. -->
|
||||
<string name="device_sync_account_label" translatable="false">Bulwark</string>
|
||||
</resources>
|
||||