Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
fbfb887e0a | ||
|
|
10db4c503a |
@@ -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
|
required: false
|
||||||
default: ""
|
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:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
id-token: write
|
||||||
|
attestations: write
|
||||||
steps:
|
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/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
@@ -30,7 +59,12 @@ jobs:
|
|||||||
java-version: 17
|
java-version: 17
|
||||||
cache: gradle
|
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
|
- uses: android-actions/setup-android@v3
|
||||||
|
with:
|
||||||
|
packages: platform-tools
|
||||||
|
|
||||||
- name: Cache Android NDK / CMake
|
- name: Cache Android NDK / CMake
|
||||||
uses: actions/cache@v4
|
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') }}
|
key: android-sdk-${{ runner.os }}-${{ hashFiles('android/build.gradle', 'android/gradle.properties', 'android/app/build.gradle') }}
|
||||||
restore-keys: android-sdk-${{ runner.os }}-
|
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
|
- name: Cache native CMake build outputs
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
@@ -50,29 +94,44 @@ jobs:
|
|||||||
android/app/build/intermediates/cxx
|
android/app/build/intermediates/cxx
|
||||||
android/app/build/intermediates/merged_native_libs
|
android/app/build/intermediates/merged_native_libs
|
||||||
android/app/build/intermediates/stripped_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 }}-
|
restore-keys: cxx-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Decode release keystore
|
# Checks the password and alias before the long build, and refuses a
|
||||||
id: keystore
|
# debug key that was uploaded as the release key by mistake.
|
||||||
|
- name: Decode and check the release key
|
||||||
|
id: key
|
||||||
env:
|
env:
|
||||||
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
run: |
|
run: |
|
||||||
if [ -n "$KEYSTORE_BASE64" ]; then
|
set -euo pipefail
|
||||||
echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore"
|
KEYSTORE="$RUNNER_TEMP/release.keystore"
|
||||||
echo "path=$RUNNER_TEMP/release.keystore" >> "$GITHUB_OUTPUT"
|
CERT="$RUNNER_TEMP/release-cert.der"
|
||||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
echo "$KEYSTORE_BASE64" | base64 -d > "$KEYSTORE"
|
||||||
else
|
if ! keytool -exportcert -keystore "$KEYSTORE" -alias "$KEY_ALIAS" \
|
||||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
-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
|
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
|
- name: Build release APK
|
||||||
working-directory: android
|
working-directory: android
|
||||||
env:
|
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_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
ORG_GRADLE_PROJECT_BULWARK_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
@@ -80,15 +139,147 @@ jobs:
|
|||||||
chmod +x ./gradlew
|
chmod +x ./gradlew
|
||||||
./gradlew assembleRelease --no-daemon
|
./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
|
id: artifact
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
VERSION=$(cat VERSION | tr -d '[:space:]')
|
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||||
SRC=android/app/build/outputs/apk/release/app-release.apk
|
SRC=android/app/build/outputs/apk/release/app-release.apk
|
||||||
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.apk"
|
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.apk"
|
||||||
cp "$SRC" "$DEST"
|
cp "$SRC" "$DEST"
|
||||||
|
sha256sum "$DEST" > "$DEST.sha256"
|
||||||
|
|
||||||
|
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 "path=$DEST" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "mapping=$MAPPING" >> "$GITHUB_OUTPUT"
|
||||||
echo "version=$VERSION" >> "$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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -96,6 +287,13 @@ jobs:
|
|||||||
path: ${{ steps.artifact.outputs.path }}
|
path: ${{ steps.artifact.outputs.path }}
|
||||||
if-no-files-found: error
|
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
|
- name: Resolve target release tag
|
||||||
id: target_tag
|
id: target_tag
|
||||||
env:
|
env:
|
||||||
@@ -115,9 +313,9 @@ jobs:
|
|||||||
echo "No release tag resolved - APK is in workflow artifacts only."
|
echo "No release tag resolved - APK is in workflow artifacts only."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Attach APK to release
|
- name: Attach APK and R8 mapping to release
|
||||||
if: steps.target_tag.outputs.tag != ''
|
if: steps.target_tag.outputs.tag != ''
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.target_tag.outputs.tag }}
|
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
|
*.p12
|
||||||
*.key
|
*.key
|
||||||
*.mobileprovision
|
*.mobileprovision
|
||||||
|
*.keystore
|
||||||
|
!/android/app/debug.keystore
|
||||||
|
*.lineage
|
||||||
|
|
||||||
# Metro / Expo dev-server logs
|
# Metro / Expo dev-server logs
|
||||||
.metro-health-check*
|
.metro-health-check*
|
||||||
|
|||||||
@@ -1,83 +1,284 @@
|
|||||||
import React from 'react';
|
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 { 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 { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack';
|
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 'lucide-react-native';
|
||||||
|
|
||||||
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 {
|
import {
|
||||||
addMessageListener,
|
addMessageListener,
|
||||||
addNotificationTapListener,
|
addNotificationTapListener,
|
||||||
addTokenRefreshListener,
|
addTokenRefreshListener,
|
||||||
getInitialNotificationTap,
|
getInitialNotificationTap,
|
||||||
getStoredRelayBaseUrl,
|
getStoredRelayBaseUrl,
|
||||||
setupPushNotifications,
|
notificationTapJmapAccountId,
|
||||||
|
resyncPushNotifications,
|
||||||
|
teardownPushNotificationsForAccount,
|
||||||
type NotificationTapPayload,
|
type NotificationTapPayload,
|
||||||
} from './src/lib/push-notifications';
|
} from './src/lib/push-notifications';
|
||||||
|
import { addUnifiedPushEndpointListener } from './src/lib/unified-push';
|
||||||
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
|
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
|
||||||
import ComposeScreen from './src/screens/ComposeScreen';
|
import ComposeScreen from './src/screens/ComposeScreen';
|
||||||
import EmailThreadScreen from './src/screens/EmailThreadScreen';
|
import EmailThreadScreen from './src/screens/EmailThreadScreen';
|
||||||
|
import EmailSourceScreen from './src/screens/EmailSourceScreen';
|
||||||
import LoginScreen from './src/screens/LoginScreen';
|
import LoginScreen from './src/screens/LoginScreen';
|
||||||
import EmailListScreen from './src/screens/EmailListScreen';
|
import MailTab from './src/screens/MailTab';
|
||||||
import FilesScreen from './src/screens/FilesScreen';
|
import FilesScreen from './src/screens/FilesScreen';
|
||||||
import CalendarScreen from './src/screens/CalendarScreenNew';
|
import CalendarScreen from './src/screens/CalendarScreen';
|
||||||
import ContactsScreen from './src/screens/ContactsScreenNew';
|
import ContactsTab from './src/screens/ContactsTab';
|
||||||
import ContactDetailScreen from './src/screens/ContactDetailScreen';
|
import ContactDetailScreen from './src/screens/ContactDetailScreen';
|
||||||
import ContactFormScreen from './src/screens/ContactFormScreen';
|
import ContactFormScreen from './src/screens/ContactFormScreen';
|
||||||
import GroupDetailScreen from './src/screens/GroupDetailScreen';
|
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 { useAccountStore } from './src/stores/account-store';
|
||||||
import { useAuthStore } from './src/stores/auth-store';
|
import { useAuthStore } from './src/stores/auth-store';
|
||||||
import { useCalendarStore } from './src/stores/calendar-store';
|
import { useCalendarStore } from './src/stores/calendar-store';
|
||||||
import { useContactsStore } from './src/stores/contacts-store';
|
import { useContactsStore } from './src/stores/contacts-store';
|
||||||
import { useEmailStore } from './src/stores/email-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 { 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 { useUpdatesStore } from './src/stores/updates-store';
|
||||||
import { UpdateBanner } from './src/components/UpdateBanner';
|
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 Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
const Tab = createBottomTabNavigator<MainTabsParamList>();
|
const Tab = createBottomTabNavigator<MainTabsParamList>();
|
||||||
const navigationRef = createNavigationContainerRef<RootStackParamList>();
|
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;
|
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', {
|
navigationRef.navigate('EmailThread', {
|
||||||
emailId: payload.emailId,
|
emailId: payload.emailId,
|
||||||
threadId: payload.threadId,
|
threadId: payload.threadId,
|
||||||
|
emailIds: [payload.emailId],
|
||||||
subject: payload.subject,
|
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 (
|
return (
|
||||||
<View style={styles.loadingContainer}>
|
<>
|
||||||
<ActivityIndicator color={colors.primary} />
|
{undo && <UndoSnackbar />}
|
||||||
<Text style={styles.loadingText}>{message}</Text>
|
<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>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParamList, 'MainTabs'>) {
|
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 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 (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: colors.background }}>
|
<View style={{ flex: 1, backgroundColor: c.background, paddingBottom: isSplit ? bottomInset : 0 }}>
|
||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
|
<OfflineCacheBanner />
|
||||||
|
<PushOnboardingPrompt />
|
||||||
|
<AppIconBadge />
|
||||||
<Tab.Navigator
|
<Tab.Navigator
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
// Matches webmail horizontal NavigationRail: bg-background, border-t border-border,
|
// Tabs out of view stop re-rendering until they are shown again.
|
||||||
// active uses foreground (white), inactive uses muted-foreground.
|
freezeOnBlur: true,
|
||||||
tabBarActiveTintColor: colors.text,
|
tabBarPosition: isSplit ? 'left' : 'bottom',
|
||||||
tabBarInactiveTintColor: colors.textSecondary,
|
tabBarVariant: isSplit ? 'material' : 'uikit',
|
||||||
tabBarStyle: {
|
// A narrow rail (icon over label), not a wide sidebar with labels
|
||||||
backgroundColor: colors.background,
|
// beside the icons.
|
||||||
borderTopColor: colors.border,
|
...(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,
|
borderTopWidth: 1,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shadowOpacity: 0,
|
shadowOpacity: 0,
|
||||||
@@ -92,11 +293,12 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
|||||||
<Tab.Screen
|
<Tab.Screen
|
||||||
name="Mail"
|
name="Mail"
|
||||||
options={{
|
options={{
|
||||||
|
title: tabLabels.mail,
|
||||||
tabBarIcon: ({ color, size }) => <Mail size={size} color={color} />,
|
tabBarIcon: ({ color, size }) => <Mail size={size} color={color} />,
|
||||||
tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined,
|
tabBarBadge: inboxUnreadCount > 0 ? (inboxUnreadCount > 99 ? '99+' : inboxUnreadCount) : undefined,
|
||||||
tabBarBadgeStyle: {
|
tabBarBadgeStyle: {
|
||||||
backgroundColor: colors.error,
|
backgroundColor: c.error,
|
||||||
color: colors.primaryForeground,
|
color: c.primaryForeground,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
minWidth: 16,
|
minWidth: 16,
|
||||||
@@ -108,43 +310,57 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{() => (
|
{() => <MailTab navigation={navigation} />}
|
||||||
<EmailListScreen
|
|
||||||
onComposePress={() => navigation.navigate('Compose')}
|
|
||||||
onEmailPress={(email) => {
|
|
||||||
navigation.navigate('EmailThread', {
|
|
||||||
emailId: email.id,
|
|
||||||
threadId: email.threadId,
|
|
||||||
subject: email.subject,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Tab.Screen>
|
</Tab.Screen>
|
||||||
<Tab.Screen
|
<Tab.Screen
|
||||||
name="Calendar"
|
name="Calendar"
|
||||||
component={CalendarScreen}
|
component={CalendarScreen}
|
||||||
options={{
|
options={{
|
||||||
|
title: tabLabels.calendar,
|
||||||
tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />,
|
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
|
<Tab.Screen
|
||||||
name="Contacts"
|
name="Contacts"
|
||||||
component={ContactsScreen}
|
component={ContactsTab}
|
||||||
options={{
|
options={{
|
||||||
|
title: tabLabels.contacts,
|
||||||
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
|
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
|
<Tab.Screen
|
||||||
name="Files"
|
name="Files"
|
||||||
component={FilesScreen}
|
component={FilesScreen}
|
||||||
options={{
|
options={{
|
||||||
|
title: tabLabels.files,
|
||||||
tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />,
|
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
|
<Tab.Screen
|
||||||
name="Settings"
|
name="Settings"
|
||||||
options={{
|
options={{
|
||||||
|
title: tabLabels.settings,
|
||||||
tabBarIcon: ({ color, size }) => <Settings size={size} color={color} />,
|
tabBarIcon: ({ color, size }) => <Settings size={size} color={color} />,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -160,11 +376,29 @@ export default function App() {
|
|||||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
const client = useAuthStore((state) => state.client);
|
const client = useAuthStore((state) => state.client);
|
||||||
const restoreSession = useAuthStore((state) => state.restoreSession);
|
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
|
// 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
|
// 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
|
// cached mail list instead of the "Restoring session" spinner; the real
|
||||||
// JMAP session comes up in the background.
|
// JMAP session comes up in the background.
|
||||||
const hasPersistedAccount = useAccountStore((state) => state.activeAccountId != null);
|
const hasPersistedAccount = useAccountStore((state) => state.activeAccountId != null);
|
||||||
|
const { isSplit } = useLayout();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!hasRestoredSession) {
|
if (!hasRestoredSession) {
|
||||||
@@ -174,8 +408,36 @@ export default function App() {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
void useSettingsStore.getState().hydrate();
|
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(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -189,12 +451,54 @@ export default function App() {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Listen for FCM messages so the app can refresh state even when woken
|
// Offline mail cache: hydrate the cache index on launch, and kick off a
|
||||||
// from the background - the FirebaseMessagingService also posts the system
|
// background sync once we have a live JMAP session and the user has the
|
||||||
// notification so the user sees it regardless of RN runtime state.
|
// 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(() => {
|
React.useEffect(() => {
|
||||||
const unsubscribe = addMessageListener((payload) => {
|
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;
|
return unsubscribe;
|
||||||
}, []);
|
}, []);
|
||||||
@@ -210,11 +514,11 @@ export default function App() {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
const initial = await getInitialNotificationTap();
|
const initial = await getInitialNotificationTap();
|
||||||
if (cancelled || !initial) return;
|
if (cancelled || !initial) return;
|
||||||
navigateToNotificationTap(initial);
|
await navigateToNotificationTap(initial);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const unsubscribe = addNotificationTapListener((payload) => {
|
const unsubscribe = addNotificationTapListener((payload) => {
|
||||||
navigateToNotificationTap(payload);
|
void navigateToNotificationTap(payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -223,17 +527,134 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, [isAuthenticated]);
|
}, [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,
|
// 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(() => {
|
React.useEffect(() => {
|
||||||
if (!isAuthenticated || !client) return;
|
if (!isAuthenticated || !client) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const doSetup = async () => {
|
const doSetup = async () => {
|
||||||
|
if (!emailNotificationsEnabled) {
|
||||||
|
if (activeAccountId) {
|
||||||
|
await teardownPushNotificationsForAccount(activeAccountId).catch(
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const relayBaseUrl = await getStoredRelayBaseUrl();
|
const relayBaseUrl = await getStoredRelayBaseUrl();
|
||||||
if (!relayBaseUrl) return;
|
if (!relayBaseUrl) return;
|
||||||
try {
|
try {
|
||||||
await setupPushNotifications({
|
await resyncPushNotifications({
|
||||||
relayBaseUrl,
|
relayBaseUrl,
|
||||||
accountLabel: client.username ?? undefined,
|
accountLabel: client.username ?? undefined,
|
||||||
});
|
});
|
||||||
@@ -250,52 +671,129 @@ export default function App() {
|
|||||||
const unsubscribe = addTokenRefreshListener(() => {
|
const unsubscribe = addTokenRefreshListener(() => {
|
||||||
void doSetup();
|
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 () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
unsubscribe();
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (!isAuthenticated || !client) {
|
if (!isAuthenticated || !client || !haveLiveSession) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mounted = true;
|
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 {
|
try {
|
||||||
const stopPushUpdates = await startPushUpdates(client, {
|
const next = await startLiveUpdates({
|
||||||
onStateChange: async (change) => {
|
onStateChange: (change) => { void onStateChange(change); },
|
||||||
await Promise.all([
|
onError: (error) => { console.warn('[push]', error.message); },
|
||||||
useEmailStore.getState().handleStateChange(change),
|
onFallback: (reason) => { console.warn('[push] falling back to polling:', reason); },
|
||||||
useContactsStore.getState().handleStateChange(change),
|
isActive: () => appActive,
|
||||||
useCalendarStore.getState().handleStateChange(change),
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.warn(error.message);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
stopPushUpdates();
|
next.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
handle = next;
|
||||||
cleanup = stopPushUpdates;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(error instanceof Error ? error.message : 'Failed to start JMAP push updates');
|
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 () => {
|
return () => {
|
||||||
mounted = false;
|
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
|
// Skip the "Restoring session" flash for returning users: if we already
|
||||||
// have a persisted active account, render the main UI immediately with
|
// have a persisted active account, render the main UI immediately with
|
||||||
@@ -304,8 +802,8 @@ export default function App() {
|
|||||||
if (!hasRestoredSession && !hasPersistedAccount) {
|
if (!hasRestoredSession && !hasPersistedAccount) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StatusBar style="light" />
|
<StatusBar style={statusBarStyle} />
|
||||||
<LoadingScreen message="Loading..." />
|
<LoadingScreen message={t('common.loading', 'Loading...')} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -313,25 +811,29 @@ export default function App() {
|
|||||||
if (hasRestoredSession && !isAuthenticated) {
|
if (hasRestoredSession && !isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StatusBar style="light" />
|
<StatusBar style={statusBarStyle} />
|
||||||
<LoginScreen />
|
<LoginScreen />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavigationContainer ref={navigationRef}>
|
<NavigationContainer ref={navigationRef} theme={navigationTheme}>
|
||||||
<StatusBar style="light" />
|
<StatusBar style={statusBarStyle} />
|
||||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
{/* 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="MainTabs" component={MainTabsNavigator} />
|
||||||
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
|
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
|
||||||
|
<Stack.Screen name="EmailSource" component={EmailSourceScreen} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Compose"
|
name="Compose"
|
||||||
component={ComposeScreen}
|
component={ComposeScreen}
|
||||||
options={{
|
// A tablet shows the composer as a panel over the screen it came
|
||||||
presentation: 'modal',
|
// from, so that screen stays drawn behind the dimmed backdrop.
|
||||||
animation: 'slide_from_bottom',
|
options={isSplit
|
||||||
}}
|
? { presentation: 'transparentModal', animation: 'fade' }
|
||||||
|
: { presentation: 'modal', animation: 'slide_from_bottom' }}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="ContactDetail" component={ContactDetailScreen} />
|
<Stack.Screen name="ContactDetail" component={ContactDetailScreen} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -343,6 +845,8 @@ export default function App() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="GroupDetail" component={GroupDetailScreen} />
|
<Stack.Screen name="GroupDetail" component={GroupDetailScreen} />
|
||||||
|
<Stack.Screen name="Scheduled" component={ScheduledScreen} />
|
||||||
|
<Stack.Screen name="UnifiedInbox" component={UnifiedInboxScreen} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="AddAccount"
|
name="AddAccount"
|
||||||
options={{
|
options={{
|
||||||
@@ -368,12 +872,10 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: colors.background,
|
|
||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
},
|
},
|
||||||
loadingText: {
|
loadingText: {
|
||||||
...typography.body,
|
...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
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 3, 19 November 2007
|
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">
|
<div align="center">
|
||||||
|
|
||||||
<picture>
|
<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: 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" />
|
<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" />
|
<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>
|
</picture>
|
||||||
|
|
||||||
</div>
|
</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.
|
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
|
## What works today
|
||||||
|
|
||||||
- Sign in to any JMAP server (e.g. Stalwart)
|
- 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
|
- Email list, threads, compose
|
||||||
- Calendar (basic)
|
- Calendar (basic)
|
||||||
- Contacts (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
|
- In-app sideload updates from GitHub Releases
|
||||||
|
|
||||||
## What's missing or rough
|
## What's missing or rough
|
||||||
|
|
||||||
- iOS build is untested
|
- iOS builds, but push notifications, client certificates and syncing with the phone's contacts and calendars are Android-only so far
|
||||||
- Filters & rules, S/MIME, plugins, themes, file storage - UI stubs only
|
- 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
|
- 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
|
## Run locally
|
||||||
|
|
||||||
@@ -40,8 +46,9 @@ npx expo start
|
|||||||
|
|
||||||
Then press `a` for Android, `i` for iOS, or scan the QR with Expo Go.
|
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
|
## 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()
|
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.
|
* 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.
|
* 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'
|
applicationId 'com.anonymous.bulwarkmobile'
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode appVersionCode.toInteger()
|
||||||
versionName appVersionName
|
versionName appVersionName
|
||||||
|
|
||||||
|
// Android account type of device sync (#34), derived from the package
|
||||||
|
// name so a later rename needs no other change. The XML in res/xml and
|
||||||
|
// the Kotlin in sync/ read it as @string/device_sync_account_type.
|
||||||
|
resValue "string", "device_sync_account_type", "${applicationId}.account"
|
||||||
|
|
||||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -107,13 +168,12 @@ android {
|
|||||||
keyAlias 'androiddebugkey'
|
keyAlias 'androiddebugkey'
|
||||||
keyPassword 'android'
|
keyPassword 'android'
|
||||||
}
|
}
|
||||||
if (project.hasProperty('BULWARK_RELEASE_STORE_FILE')
|
if (releaseSigningConfigured) {
|
||||||
&& project.property('BULWARK_RELEASE_STORE_FILE')?.trim()) {
|
|
||||||
release {
|
release {
|
||||||
storeFile file(project.property('BULWARK_RELEASE_STORE_FILE'))
|
storeFile releaseStoreFile
|
||||||
storePassword project.property('BULWARK_RELEASE_STORE_PASSWORD')
|
storePassword releaseSigning.BULWARK_RELEASE_STORE_PASSWORD
|
||||||
keyAlias project.property('BULWARK_RELEASE_KEY_ALIAS')
|
keyAlias releaseSigning.BULWARK_RELEASE_KEY_ALIAS
|
||||||
keyPassword project.property('BULWARK_RELEASE_KEY_PASSWORD')
|
keyPassword releaseSigning.BULWARK_RELEASE_KEY_PASSWORD
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,7 +182,13 @@ android {
|
|||||||
signingConfig signingConfigs.debug
|
signingConfig signingConfigs.debug
|
||||||
}
|
}
|
||||||
release {
|
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'
|
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
|
||||||
shrinkResources enableShrinkResources.toBoolean()
|
shrinkResources enableShrinkResources.toBoolean()
|
||||||
minifyEnabled enableMinifyInReleaseBuilds
|
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`
|
// Apply static values from `gradle.properties` to the `android.packagingOptions`
|
||||||
// Accepts values in comma delimited lists, example:
|
// Accepts values in comma delimited lists, example:
|
||||||
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
|
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
|
||||||
@@ -171,6 +279,17 @@ dependencies {
|
|||||||
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
|
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
|
||||||
implementation("com.google.firebase:firebase-messaging")
|
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 isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
|
||||||
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
|
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
|
||||||
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
|
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
|
||||||
|
|||||||
@@ -12,3 +12,12 @@
|
|||||||
-keep class com.facebook.react.turbomodule.** { *; }
|
-keep class com.facebook.react.turbomodule.** { *; }
|
||||||
|
|
||||||
# Add any project specific keep options here:
|
# 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"/>
|
<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>
|
</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">
|
<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.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
<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>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.VIEW"/>
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
@@ -13,15 +21,52 @@
|
|||||||
<data android:scheme="https"/>
|
<data android:scheme="https"/>
|
||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="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.ENABLED" android:value="false"/>
|
||||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
||||||
<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>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
<service android:name=".BulwarkMessagingService" android:exported="false">
|
<service android:name=".BulwarkMessagingService" android:exported="false">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -29,6 +74,263 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
<service android:name=".BulwarkPushTaskService" android:exported="false"/>
|
<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"/>
|
<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>
|
</application>
|
||||||
</manifest>
|
</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.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
@@ -19,6 +20,7 @@ import com.facebook.react.bridge.ReadableMap
|
|||||||
import com.facebook.react.bridge.WritableMap
|
import com.facebook.react.bridge.WritableMap
|
||||||
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
|
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
|
||||||
import com.google.firebase.messaging.FirebaseMessaging
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
@@ -64,6 +66,17 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
val emailId = options.getString("emailId")
|
val emailId = options.getString("emailId")
|
||||||
val threadId = options.getString("threadId")
|
val threadId = options.getString("threadId")
|
||||||
val subject = options.takeIf { it.hasKey("subject") }?.getString("subject")
|
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
|
// Bitmap fetch + draw off the bridge thread so the caller doesn't
|
||||||
// block waiting for the favicon request.
|
// block waiting for the favicon request.
|
||||||
@@ -71,9 +84,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
val largeIcon = iconUrl?.let { fetchBitmap(it) }
|
val largeIcon = iconUrl?.let { fetchBitmap(it) }
|
||||||
?: makeLetterAvatar(initials, bgColorHex)
|
?: makeLetterAvatar(initials, bgColorHex)
|
||||||
postNotification(
|
postNotification(
|
||||||
notificationId, title, body, largeIcon, bgColorHex,
|
notificationId, title, body, preview, largeIcon, bgColorHex,
|
||||||
emailId, threadId, subject,
|
emailId, threadId, subject, accountId, jmapAccountId, groupKey,
|
||||||
|
groupTitle, markReadLabel, deleteLabel, replyLabel,
|
||||||
)
|
)
|
||||||
|
if (groupKey != null) postGroupSummary(groupKey, groupTitle, bgColorHex, accountId)
|
||||||
promise.resolve(null)
|
promise.resolve(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,22 +99,39 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
promise.resolve(payload?.toMap())
|
promise.resolve(payload?.toMap())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ReactMethod
|
||||||
|
fun getInitialShare(promise: Promise) {
|
||||||
|
val payload = ShareIntentStore.consume()
|
||||||
|
promise.resolve(payload?.toMap())
|
||||||
|
}
|
||||||
|
|
||||||
private fun postNotification(
|
private fun postNotification(
|
||||||
notificationId: String,
|
notificationId: String,
|
||||||
title: String,
|
title: String,
|
||||||
body: String,
|
body: String,
|
||||||
|
preview: String?,
|
||||||
largeIcon: Bitmap,
|
largeIcon: Bitmap,
|
||||||
colorHex: String,
|
colorHex: String,
|
||||||
emailId: String?,
|
emailId: String?,
|
||||||
threadId: String?,
|
threadId: String?,
|
||||||
subject: String?,
|
subject: String?,
|
||||||
|
accountId: String?,
|
||||||
|
jmapAccountId: String?,
|
||||||
|
groupKey: String?,
|
||||||
|
groupTitle: String?,
|
||||||
|
markReadLabel: String,
|
||||||
|
deleteLabel: String,
|
||||||
|
replyLabel: String,
|
||||||
) {
|
) {
|
||||||
val ctx = reactApplicationContext
|
val ctx = reactApplicationContext
|
||||||
val intent = Intent(ctx, MainActivity::class.java).apply {
|
val intent = Intent(ctx, MainActivity::class.java).apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
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 (emailId != null) putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||||
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
|
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
|
||||||
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
|
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(
|
val pending = PendingIntent.getActivity(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -108,21 +140,160 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
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)
|
val builder = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
|
||||||
.setSmallIcon(android.R.drawable.ic_dialog_email)
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
.setLargeIcon(largeIcon)
|
.setLargeIcon(largeIcon)
|
||||||
.setContentTitle(title)
|
.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)
|
.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")))
|
.setColor(parseColor(colorHex, fallback = Color.parseColor("#2563eb")))
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
.setContentIntent(pending)
|
.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
|
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
manager.notify(notificationId, notificationId.hashCode(), builder.build())
|
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 {
|
private fun makeLetterAvatar(initials: String, bgHex: String): Bitmap {
|
||||||
val size = 192
|
val size = 192
|
||||||
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||||
@@ -148,7 +319,36 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
conn.readTimeout = 3000
|
conn.readTimeout = 3000
|
||||||
conn.instanceFollowRedirects = true
|
conn.instanceFollowRedirects = true
|
||||||
conn.setRequestProperty("User-Agent", "bulwark-mobile")
|
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) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@@ -167,8 +367,16 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
fun removeListeners(count: Int) {}
|
fun removeListeners(count: Int) {}
|
||||||
|
|
||||||
companion object {
|
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
|
@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?) {
|
fun emit(eventName: String, params: WritableMap?) {
|
||||||
val module = currentInstance ?: return
|
val module = currentInstance ?: return
|
||||||
val ctx = module.reactApplicationContext
|
val ctx = module.reactApplicationContext
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
package com.anonymous.bulwarkmobile
|
package com.anonymous.bulwarkmobile
|
||||||
|
|
||||||
|
import com.anonymous.bulwarkmobile.sync.BulwarkDeviceSyncModule
|
||||||
import com.facebook.react.ReactPackage
|
import com.facebook.react.ReactPackage
|
||||||
import com.facebook.react.bridge.NativeModule
|
import com.facebook.react.bridge.NativeModule
|
||||||
import com.facebook.react.bridge.ReactApplicationContext
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
|
import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags
|
||||||
import com.facebook.react.uimanager.ViewManager
|
import com.facebook.react.uimanager.ViewManager
|
||||||
|
|
||||||
class BulwarkFcmPackage : ReactPackage {
|
class BulwarkFcmPackage : ReactPackage {
|
||||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
|
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<*, *>> =
|
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
|
||||||
emptyList()
|
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.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import com.facebook.react.HeadlessJsTaskService
|
import com.anonymous.bulwarkmobile.sync.DeviceSyncPushRouter
|
||||||
import com.facebook.react.bridge.Arguments
|
import com.facebook.react.bridge.Arguments
|
||||||
import com.google.firebase.messaging.FirebaseMessagingService
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
import com.google.firebase.messaging.RemoteMessage
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
@@ -25,13 +27,17 @@ class BulwarkMessagingService : FirebaseMessagingService() {
|
|||||||
|
|
||||||
val data = message.data
|
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
|
// Hand off to JS via a headless task only when the app isn't already
|
||||||
// running in the foreground - HeadlessJsTaskContext throws if started
|
// running in the foreground - HeadlessJsTaskContext throws if started
|
||||||
// while foreground. When the app is open, the main JS instance already
|
// while foreground. When the app is open, the main JS instance already
|
||||||
// receives JMAP push directly, so the fcm:message event below is
|
// receives JMAP push directly, so the fcm:message event below is
|
||||||
// sufficient for it to refresh state.
|
// sufficient for it to refresh state.
|
||||||
if (!isAppInForeground()) {
|
if (mail && !isAppInForeground()) {
|
||||||
startHeadlessTask(data)
|
startHeadlessTask(applicationContext, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
val params = Arguments.createMap().apply {
|
val params = Arguments.createMap().apply {
|
||||||
@@ -42,25 +48,60 @@ class BulwarkMessagingService : FirebaseMessagingService() {
|
|||||||
BulwarkFcmModule.emit("fcm:message", params)
|
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 {
|
companion object {
|
||||||
const val CHANNEL_ID = "bulwark_mail"
|
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) {
|
fun ensureChannel(context: Context) {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
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
|
package com.anonymous.bulwarkmobile
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
import com.facebook.react.bridge.Arguments
|
import com.facebook.react.bridge.Arguments
|
||||||
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
import com.facebook.react.jstasks.HeadlessJsTaskConfig
|
||||||
import com.facebook.react.HeadlessJsTaskService
|
import com.facebook.react.HeadlessJsTaskService
|
||||||
|
|
||||||
class BulwarkPushTaskService : 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? {
|
override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig? {
|
||||||
val extras = intent?.extras ?: return null
|
val extras = intent?.extras ?: return null
|
||||||
return HeadlessJsTaskConfig(
|
return taskConfig(extras)
|
||||||
"BulwarkPushTask",
|
}
|
||||||
Arguments.fromBundle(extras),
|
|
||||||
30000L,
|
companion object {
|
||||||
false,
|
// 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.
|
// coloring the background, status bar, and navigation bar.
|
||||||
// This is required for expo-splash-screen.
|
// This is required for expo-splash-screen.
|
||||||
setTheme(R.style.AppTheme);
|
setTheme(R.style.AppTheme);
|
||||||
|
ShareIntentStore.rewriteSendToAsView(intent)
|
||||||
super.onCreate(null)
|
super.onCreate(null)
|
||||||
NotificationTapStore.captureFromIntent(intent)
|
NotificationTapStore.captureFromIntent(intent)
|
||||||
|
ShareIntentStore.captureFromIntent(intent, contentResolver)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
ShareIntentStore.rewriteSendToAsView(intent)
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
|
ShareIntentStore.captureFromIntent(intent, contentResolver)?.let { share ->
|
||||||
|
BulwarkFcmModule.emit("app:share", share.toMap())
|
||||||
|
}
|
||||||
val payload = NotificationTapStore.captureFromIntent(intent) ?: return
|
val payload = NotificationTapStore.captureFromIntent(intent) ?: return
|
||||||
BulwarkFcmModule.emit("fcm:notificationTap", payload.toMap())
|
BulwarkFcmModule.emit("fcm:notificationTap", payload.toMap())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,17 +17,42 @@ object NotificationTapStore {
|
|||||||
return value
|
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? {
|
fun captureFromIntent(intent: Intent?): TapPayload? {
|
||||||
val extras = intent?.extras ?: return null
|
val extras = intent?.extras ?: return null
|
||||||
val emailId = extras.getString(EXTRA_EMAIL_ID) ?: return null
|
val emailId = extras.getString(EXTRA_EMAIL_ID)?.takeIf { ID_PATTERN.matches(it) }
|
||||||
val threadId = extras.getString(EXTRA_THREAD_ID) ?: return null
|
?: return null
|
||||||
val subject = extras.getString(EXTRA_SUBJECT)
|
val threadId = extras.getString(EXTRA_THREAD_ID)?.takeIf { ID_PATTERN.matches(it) }
|
||||||
val payload = TapPayload(emailId, threadId, subject)
|
?: 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
|
pending = payload
|
||||||
// Clear so a subsequent activity lifecycle event doesn't replay this.
|
// Clear so a subsequent activity lifecycle event doesn't replay this.
|
||||||
extras.remove(EXTRA_EMAIL_ID)
|
extras.remove(EXTRA_EMAIL_ID)
|
||||||
extras.remove(EXTRA_THREAD_ID)
|
extras.remove(EXTRA_THREAD_ID)
|
||||||
extras.remove(EXTRA_SUBJECT)
|
extras.remove(EXTRA_SUBJECT)
|
||||||
|
extras.remove(EXTRA_ACCOUNT_ID)
|
||||||
|
extras.remove(EXTRA_JMAP_ACCOUNT_ID)
|
||||||
|
extras.remove(EXTRA_ACTION)
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,15 +60,24 @@ object NotificationTapStore {
|
|||||||
val emailId: String,
|
val emailId: String,
|
||||||
val threadId: String,
|
val threadId: String,
|
||||||
val subject: String?,
|
val subject: String?,
|
||||||
|
val accountId: String?,
|
||||||
|
val jmapAccountId: String?,
|
||||||
|
val action: String? = null,
|
||||||
) {
|
) {
|
||||||
fun toMap(): WritableMap = Arguments.createMap().apply {
|
fun toMap(): WritableMap = Arguments.createMap().apply {
|
||||||
putString("emailId", emailId)
|
putString("emailId", emailId)
|
||||||
putString("threadId", threadId)
|
putString("threadId", threadId)
|
||||||
if (subject != null) putString("subject", subject)
|
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_EMAIL_ID = "bulwark.notification.emailId"
|
||||||
const val EXTRA_THREAD_ID = "bulwark.notification.threadId"
|
const val EXTRA_THREAD_ID = "bulwark.notification.threadId"
|
||||||
const val EXTRA_SUBJECT = "bulwark.notification.subject"
|
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>
|
<resources>
|
||||||
<color name="splashscreen_background">#09090b</color>
|
<!-- Splash background, as `splash.backgroundColor` in app.config.js; the dark one is in values-night. -->
|
||||||
<color name="iconBackground">#09090b</color>
|
<color name="splashscreen_background">#ffffff</color>
|
||||||
|
<color name="iconBackground">#db2d54</color>
|
||||||
<color name="colorPrimary">#023c69</color>
|
<color name="colorPrimary">#023c69</color>
|
||||||
<color name="colorPrimaryDark">#09090b</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>
|
</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="app_name">Bulwark Mobile</string>
|
||||||
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
|
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
|
||||||
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</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>
|
</resources>
|
||||||