mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2026-02-05 20:51:21 -05:00
Compare commits
713 Commits
v4.3.16-os
...
split-core
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38bad81c55 | ||
|
|
31a7ad0939 | ||
|
|
5e84648fb4 | ||
|
|
490abcb88a | ||
|
|
cca12e79d8 | ||
|
|
915cf73027 | ||
|
|
53773eaf83 | ||
|
|
9cd685982d | ||
|
|
d4902e84ce | ||
|
|
ec485fcfa5 | ||
|
|
5709aaa2e5 | ||
|
|
a19c397ef6 | ||
|
|
dad4298dd5 | ||
|
|
8e78e6e3ac | ||
|
|
fc878d519f | ||
|
|
45d5d809fc | ||
|
|
ef1d90f740 | ||
|
|
5efcbfc5a3 | ||
|
|
4f3ff69b43 | ||
|
|
afe00c275e | ||
|
|
03c4aa9938 | ||
|
|
63a5359c06 | ||
|
|
89a7cd2885 | ||
|
|
db25570581 | ||
|
|
3de34e53d0 | ||
|
|
af084fb5d0 | ||
|
|
47685e6693 | ||
|
|
2c7b36ecd5 | ||
|
|
cf80b11808 | ||
|
|
18649f711a | ||
|
|
377a159e75 | ||
|
|
393d22f720 | ||
|
|
5b12ecf6b6 | ||
|
|
f8f6134640 | ||
|
|
0f7908da23 | ||
|
|
40741f52e1 | ||
|
|
210a03bd1a | ||
|
|
714c92b8d9 | ||
|
|
5ea937a0f9 | ||
|
|
126b742887 | ||
|
|
2de7e09c82 | ||
|
|
0312f59aab | ||
|
|
b3682ded1a | ||
|
|
529378000a | ||
|
|
8644c94c34 | ||
|
|
5357bdefb8 | ||
|
|
07b646e4e7 | ||
|
|
77cb3e659d | ||
|
|
42d65c872e | ||
|
|
c3fd28f820 | ||
|
|
9ad98e4a16 | ||
|
|
27c1be948f | ||
|
|
ac7ef1a7e5 | ||
|
|
e0eb13f57b | ||
|
|
85c4fc76f2 | ||
|
|
6bd9422f3b | ||
|
|
9754770238 | ||
|
|
6be15fd366 | ||
|
|
0cb27f0c2f | ||
|
|
776305bd12 | ||
|
|
01f54df3c0 | ||
|
|
4944ce59b1 | ||
|
|
0e455d8371 | ||
|
|
a938b511cd | ||
|
|
d32b86789b | ||
|
|
84d58f73db | ||
|
|
cc43998148 | ||
|
|
b354bfebc2 | ||
|
|
29240ea16f | ||
|
|
e7b88f9aa8 | ||
|
|
4d71517cde | ||
|
|
0d4f154baf | ||
|
|
9eb70a5564 | ||
|
|
24e0a864bd | ||
|
|
10ec0c3b6d | ||
|
|
bd3349cc38 | ||
|
|
c7bc2b317b | ||
|
|
2d10cbb07d | ||
|
|
88928792af | ||
|
|
b5e8c80db1 | ||
|
|
6f09f55e1a | ||
|
|
b08f10a98f | ||
|
|
a3a952d875 | ||
|
|
e9fc570895 | ||
|
|
098b7d5b12 | ||
|
|
a38dc29cca | ||
|
|
84b9a14ba1 | ||
|
|
cda95dc789 | ||
|
|
f64882ca2a | ||
|
|
7c2dcf3d70 | ||
|
|
66a34ebd9f | ||
|
|
365364aa89 | ||
|
|
114543f4c5 | ||
|
|
3bd3f56e1b | ||
|
|
5263172376 | ||
|
|
babd52cfb1 | ||
|
|
3d4d533b92 | ||
|
|
76fc024ef6 | ||
|
|
794b4c1c7f | ||
|
|
aac6356722 | ||
|
|
9bc46d4194 | ||
|
|
a3aac44775 | ||
|
|
084ba3b630 | ||
|
|
28dcf90775 | ||
|
|
70766affd9 | ||
|
|
d00292f421 | ||
|
|
6b5c4f191a | ||
|
|
5c7b792e7f | ||
|
|
c9da496142 | ||
|
|
ee098c4a83 | ||
|
|
a8bd296520 | ||
|
|
0959624dee | ||
|
|
bd13d27e38 | ||
|
|
85548163ca | ||
|
|
026750eca3 | ||
|
|
d365a504e8 | ||
|
|
c64cb1e7ec | ||
|
|
837b5e5d50 | ||
|
|
98aefc4fee | ||
|
|
a8c8a8d2e0 | ||
|
|
b839cbfe7f | ||
|
|
f0f9f58e49 | ||
|
|
66f6e48e3b | ||
|
|
d6feda1142 | ||
|
|
05f058ab3f | ||
|
|
4e4c0f5e31 | ||
|
|
0304d7168a | ||
|
|
19458aa95c | ||
|
|
4412617079 | ||
|
|
76277dbfd5 | ||
|
|
b0b99de56b | ||
|
|
b33c4750bb | ||
|
|
25b749dd1b | ||
|
|
39a0fe3f98 | ||
|
|
dd798f8380 | ||
|
|
47d380de62 | ||
|
|
019d32a9b7 | ||
|
|
0acabd9c80 | ||
|
|
aa23980a59 | ||
|
|
5674d6b954 | ||
|
|
df56c8628a | ||
|
|
af4ecd3a1d | ||
|
|
701e292ab5 | ||
|
|
5da7d9e292 | ||
|
|
e6256764ac | ||
|
|
e63f815416 | ||
|
|
374dadfaaa | ||
|
|
f6ef13f9fe | ||
|
|
a88cfd2acf | ||
|
|
98fc946594 | ||
|
|
00523d9bc8 | ||
|
|
53d338d03e | ||
|
|
0424999225 | ||
|
|
fa09a0560f | ||
|
|
f4aa55d482 | ||
|
|
1bffd5efe1 | ||
|
|
4850f2a5a5 | ||
|
|
53f38ce2ec | ||
|
|
7cf6e30577 | ||
|
|
cec77c33cb | ||
|
|
6c98e6d501 | ||
|
|
a7cd1cd49f | ||
|
|
0cc84dfd01 | ||
|
|
87239daaf6 | ||
|
|
81ceb57842 | ||
|
|
cd0b0c0804 | ||
|
|
48cbd4a05d | ||
|
|
beccc7a0d4 | ||
|
|
2b629c8b18 | ||
|
|
cd725479cd | ||
|
|
44666d2138 | ||
|
|
8e67db7d54 | ||
|
|
a58e3b9036 | ||
|
|
d63918ff42 | ||
|
|
f21c3de94a | ||
|
|
24d4ba65e5 | ||
|
|
ae96f1ffbb | ||
|
|
a08ecae635 | ||
|
|
eb4224780a | ||
|
|
0240e67dab | ||
|
|
0ccd9d5eb3 | ||
|
|
438f967152 | ||
|
|
a093238864 | ||
|
|
293daf1e82 | ||
|
|
3b50747ce9 | ||
|
|
51d6ed279a | ||
|
|
2c6842ac0c | ||
|
|
0e6644305a | ||
|
|
10e3b0a723 | ||
|
|
2f45b705b3 | ||
|
|
be6c3311d7 | ||
|
|
755863778b | ||
|
|
0e81866d3a | ||
|
|
93a256ee75 | ||
|
|
61e9d60b7c | ||
|
|
dc9fb7b608 | ||
|
|
44b52f65a2 | ||
|
|
e13c140554 | ||
|
|
cdb50205f4 | ||
|
|
2ba4a2a510 | ||
|
|
38b2377760 | ||
|
|
10f6356a6e | ||
|
|
df4b6d3fbc | ||
|
|
dab948730e | ||
|
|
288583bfad | ||
|
|
98c0b0c36a | ||
|
|
ed7a477d3f | ||
|
|
b0609fafb2 | ||
|
|
94a85833bc | ||
|
|
4c5c8c3ed0 | ||
|
|
4685ab6d0c | ||
|
|
62a0ba3520 | ||
|
|
71f3558b4b | ||
|
|
22d933096f | ||
|
|
666b707854 | ||
|
|
39f6b82926 | ||
|
|
b02fd23f0a | ||
|
|
ca56380c29 | ||
|
|
ba9eb1446b | ||
|
|
055599c74f | ||
|
|
62db3da579 | ||
|
|
76d8d5acbf | ||
|
|
dd294a4b03 | ||
|
|
0efe6a7b9b | ||
|
|
405b7abb39 | ||
|
|
4e2640ca01 | ||
|
|
904c8ba29b | ||
|
|
62dc73c2a0 | ||
|
|
58344099f7 | ||
|
|
b62c7eff0b | ||
|
|
12cedd4010 | ||
|
|
3a0221c749 | ||
|
|
f78e7868e8 | ||
|
|
5dbaedfa60 | ||
|
|
6187f92efd | ||
|
|
82ccf6a2f9 | ||
|
|
0f9c5027d4 | ||
|
|
7b76df3e70 | ||
|
|
80cfe1013d | ||
|
|
3e3c346019 | ||
|
|
1773dff8a4 | ||
|
|
604b0aab98 | ||
|
|
35cffa603b | ||
|
|
89c3eacd36 | ||
|
|
4246ed65ac | ||
|
|
789e7f3045 | ||
|
|
66f99f7362 | ||
|
|
90b04ddbdc | ||
|
|
a7f8ea8a48 | ||
|
|
42cd8d8631 | ||
|
|
a26847cf10 | ||
|
|
0e6c26aec6 | ||
|
|
2204027993 | ||
|
|
e7189d66b0 | ||
|
|
c517647819 | ||
|
|
0780b226ff | ||
|
|
8d9a417753 | ||
|
|
008c314b80 | ||
|
|
f0019c54b1 | ||
|
|
51ad6ee00a | ||
|
|
dd453a7837 | ||
|
|
f91c968eb8 | ||
|
|
968a43f9cc | ||
|
|
ac965b411b | ||
|
|
1608384418 | ||
|
|
f25c22eba5 | ||
|
|
b1f742fb3a | ||
|
|
47c8a0589d | ||
|
|
7c6474ce91 | ||
|
|
de8c1d160d | ||
|
|
fa50fe4c30 | ||
|
|
ba4d3b2fd1 | ||
|
|
0fed85fdc3 | ||
|
|
6fbaea9487 | ||
|
|
fc2bc8aa47 | ||
|
|
0321e4ab8f | ||
|
|
711543c5f1 | ||
|
|
5c485834e9 | ||
|
|
f349f1fec8 | ||
|
|
e6413506cb | ||
|
|
d4b5039297 | ||
|
|
979f2257de | ||
|
|
3efb8d5c62 | ||
|
|
ec657519a9 | ||
|
|
a835557b35 | ||
|
|
19f86670bf | ||
|
|
f74d14e2a2 | ||
|
|
57ef059099 | ||
|
|
f157a819b7 | ||
|
|
9bbc4c096d | ||
|
|
b306219015 | ||
|
|
469c30b511 | ||
|
|
05f6c7ab0b | ||
|
|
eeb94d4039 | ||
|
|
7bf9172bdc | ||
|
|
dec5be5690 | ||
|
|
d8e8129d7b | ||
|
|
c9fb7dc7a2 | ||
|
|
ededcb98e1 | ||
|
|
fc10a315d5 | ||
|
|
cfeb6b3974 | ||
|
|
a15902e586 | ||
|
|
47afddbd08 | ||
|
|
5f647b7403 | ||
|
|
d460e4ca7b | ||
|
|
827a1b954f | ||
|
|
54bcda1bb4 | ||
|
|
7003c5f730 | ||
|
|
4ccf99ce23 | ||
|
|
3fca4d60f1 | ||
|
|
2099f47d22 | ||
|
|
c12a723a52 | ||
|
|
eb0b75a9a7 | ||
|
|
94ca9cd871 | ||
|
|
9f697f06be | ||
|
|
ba7f95aad5 | ||
|
|
993fffaa15 | ||
|
|
4b7f7ed45e | ||
|
|
7e80607a34 | ||
|
|
d0389f13fc | ||
|
|
993d0f66ec | ||
|
|
4c9ad959dd | ||
|
|
044a28138a | ||
|
|
e8ec98c257 | ||
|
|
fe0c1e67e7 | ||
|
|
7261a8137d | ||
|
|
b1493f3f6a | ||
|
|
d679dc5e97 | ||
|
|
b0f7196f2b | ||
|
|
77a6e5c5ab | ||
|
|
845d979046 | ||
|
|
f62509ed80 | ||
|
|
e79c362f46 | ||
|
|
5c35741226 | ||
|
|
b90b8ce6a2 | ||
|
|
321aeedd8f | ||
|
|
09f68a237b | ||
|
|
93d715bb99 | ||
|
|
04fe8e1aca | ||
|
|
1f02f3cc27 | ||
|
|
a0acd4e929 | ||
|
|
3901e6ebe4 | ||
|
|
f229226521 | ||
|
|
6644e4acd7 | ||
|
|
d46f8056a5 | ||
|
|
c3731ace88 | ||
|
|
038c2df524 | ||
|
|
400318b390 | ||
|
|
0d2e5a1f07 | ||
|
|
9835cd0d53 | ||
|
|
f6d8efcd26 | ||
|
|
955de83b35 | ||
|
|
29a09f2038 | ||
|
|
fff332f31f | ||
|
|
b4d4a2fddd | ||
|
|
566a539a85 | ||
|
|
e588ada891 | ||
|
|
0c89e3ba3b | ||
|
|
7b0e134c20 | ||
|
|
8f7c285cb7 | ||
|
|
a2cddfc012 | ||
|
|
54eaecc6b5 | ||
|
|
0012dec482 | ||
|
|
ced6abea3f | ||
|
|
4a82baeaea | ||
|
|
f41b4fd59d | ||
|
|
f6bd4b0fc2 | ||
|
|
50879b6a0c | ||
|
|
6be42d4ec3 | ||
|
|
a56d42d9a5 | ||
|
|
e34952bca9 | ||
|
|
9d293a00e7 | ||
|
|
1ee41f8027 | ||
|
|
d3c1dbb5da | ||
|
|
cd554d885b | ||
|
|
d9b4149d41 | ||
|
|
4af6165094 | ||
|
|
fb2023762d | ||
|
|
946c450036 | ||
|
|
969d92d037 | ||
|
|
6998f009c4 | ||
|
|
cba1f01bdb | ||
|
|
5f80c8e779 | ||
|
|
5ece438b3f | ||
|
|
acd4e41f8b | ||
|
|
ae08093906 | ||
|
|
dd456b41f1 | ||
|
|
30283f36a4 | ||
|
|
4858dd9229 | ||
|
|
b910ba25ae | ||
|
|
2a542210ca | ||
|
|
eef85f1f7a | ||
|
|
4f2d4e3a49 | ||
|
|
feccb76ce8 | ||
|
|
835689a4a6 | ||
|
|
50cbac147e | ||
|
|
d1dd2f016e | ||
|
|
bd5e26a9a9 | ||
|
|
2d686bee01 | ||
|
|
2438f1a8d4 | ||
|
|
331f8d5743 | ||
|
|
7e43524ff5 | ||
|
|
708d94b69b | ||
|
|
8b3c36f702 | ||
|
|
ca2d57cf61 | ||
|
|
1cc9b4bdd1 | ||
|
|
226583f19e | ||
|
|
f3333b7b54 | ||
|
|
226560230d | ||
|
|
6a08497b3a | ||
|
|
356183084f | ||
|
|
5ea7273c94 | ||
|
|
843013a0f0 | ||
|
|
ac8de37b6f | ||
|
|
62dc374774 | ||
|
|
1f83e1bf12 | ||
|
|
4c9b67a9e5 | ||
|
|
4cbe03b351 | ||
|
|
365f87991a | ||
|
|
77a795dfe5 | ||
|
|
794007fa38 | ||
|
|
1e17e1883b | ||
|
|
48ecb5e008 | ||
|
|
f503ce5ff6 | ||
|
|
98578feeb2 | ||
|
|
0762cc6c27 | ||
|
|
b267291e93 | ||
|
|
eb8db47cea | ||
|
|
7384feeafb | ||
|
|
d10add8367 | ||
|
|
51bd163069 | ||
|
|
90280066ee | ||
|
|
03a52e96ad | ||
|
|
5890b3cc5e | ||
|
|
a02bc56b44 | ||
|
|
4939c9fc4d | ||
|
|
c2524b085e | ||
|
|
d892dd2b9c | ||
|
|
95ebce5722 | ||
|
|
4b2f032a57 | ||
|
|
bc596edfb3 | ||
|
|
e18534ab9f | ||
|
|
9ae03dbc6f | ||
|
|
042dd3fba2 | ||
|
|
5d6959c47e | ||
|
|
239038ab77 | ||
|
|
7097bf9523 | ||
|
|
53bc5a6641 | ||
|
|
9e060f6651 | ||
|
|
cc8fc4734f | ||
|
|
0733fef213 | ||
|
|
f977cc01eb | ||
|
|
30dc2cb221 | ||
|
|
fc6b605693 | ||
|
|
4f1176fd99 | ||
|
|
4ff7ff8746 | ||
|
|
2f26c6c365 | ||
|
|
d8bff41bc4 | ||
|
|
878e2bb3ad | ||
|
|
dc1c72cdd3 | ||
|
|
1dc7f3de64 | ||
|
|
d20c613044 | ||
|
|
fe8eabce1b | ||
|
|
b5790bfd09 | ||
|
|
9e7de1c8ca | ||
|
|
15d2072f16 | ||
|
|
0f4e48ad4d | ||
|
|
41075e442c | ||
|
|
3a16b5ca3f | ||
|
|
32925dc18b | ||
|
|
cf15dd3e0e | ||
|
|
3317a8d355 | ||
|
|
154d1e6bc8 | ||
|
|
1a19d5cd17 | ||
|
|
b721e83377 | ||
|
|
f69533b049 | ||
|
|
d00353ba9c | ||
|
|
dc0d4f371a | ||
|
|
3d198f5454 | ||
|
|
1802740a2d | ||
|
|
138e517d23 | ||
|
|
166b2ac220 | ||
|
|
450a418994 | ||
|
|
d4e9e2a8f7 | ||
|
|
ecc59dda99 | ||
|
|
9c2afbab09 | ||
|
|
cebf2d9dfd | ||
|
|
5f49c675c8 | ||
|
|
62c46e123d | ||
|
|
5f1215801d | ||
|
|
930977c44b | ||
|
|
a0d152a66f | ||
|
|
a8883427bc | ||
|
|
7a8dbef80b | ||
|
|
4a40bb3d6f | ||
|
|
c805e549ff | ||
|
|
26a670c181 | ||
|
|
5b54c9dff0 | ||
|
|
1ca73b67a4 | ||
|
|
194c587476 | ||
|
|
1193027e5f | ||
|
|
7de7980860 | ||
|
|
fc7f42c6fa | ||
|
|
196bfb3aea | ||
|
|
cb5798833d | ||
|
|
a1148613e9 | ||
|
|
12529fa9bd | ||
|
|
d743d19a3d | ||
|
|
4dcee27e22 | ||
|
|
b6ceaa7efc | ||
|
|
5c6f712d32 | ||
|
|
5180b99af2 | ||
|
|
dcb7e315b9 | ||
|
|
111481cd00 | ||
|
|
4dc7df7c53 | ||
|
|
cf609288e1 | ||
|
|
0b9d4cd3b3 | ||
|
|
0581417bba | ||
|
|
f8fb016a27 | ||
|
|
8c3d1cdeae | ||
|
|
4a4dc24cdf | ||
|
|
49a51ef384 | ||
|
|
fc698040aa | ||
|
|
6cbd71ab50 | ||
|
|
47f078dcd7 | ||
|
|
be6ab8728c | ||
|
|
2908bba298 | ||
|
|
b962b68631 | ||
|
|
fca7c09105 | ||
|
|
60c6aba2d2 | ||
|
|
70f6f2603e | ||
|
|
5d4c9c8d94 | ||
|
|
4378bee042 | ||
|
|
3776b50bbc | ||
|
|
a9c7e1929f | ||
|
|
318b9be77e | ||
|
|
26cb845950 | ||
|
|
eae6d0c578 | ||
|
|
6df0925e50 | ||
|
|
f7ee1ea931 | ||
|
|
16731d3a5a | ||
|
|
54e09acca3 | ||
|
|
46698a76b5 | ||
|
|
e26a8519ff | ||
|
|
410c70a47d | ||
|
|
bf1bdfc8ab | ||
|
|
5cbbfb39aa | ||
|
|
62d5a21d05 | ||
|
|
2a7cf1ae17 | ||
|
|
1062eaa58a | ||
|
|
69bde87589 | ||
|
|
4111fe08d2 | ||
|
|
19fb969040 | ||
|
|
74a22cd24d | ||
|
|
4e264076d1 | ||
|
|
c3fe1b04e5 | ||
|
|
342314363b | ||
|
|
d0358a9980 | ||
|
|
fbcf6996ad | ||
|
|
cf994ee82e | ||
|
|
9880dd5158 | ||
|
|
8e7d289971 | ||
|
|
d4c05b9282 | ||
|
|
768f462549 | ||
|
|
cbd9a55c15 | ||
|
|
4e496265e4 | ||
|
|
73d0b63705 | ||
|
|
a361888d94 | ||
|
|
907b38fd6a | ||
|
|
bf0e169cf1 | ||
|
|
fd2b3f0018 | ||
|
|
6217582677 | ||
|
|
d03dc1f37d | ||
|
|
d33e4dcb23 | ||
|
|
f4c02d4ab6 | ||
|
|
fafa358dd8 | ||
|
|
d5d6592ae2 | ||
|
|
ac48e65b1a | ||
|
|
26f95db62a | ||
|
|
5c4d697767 | ||
|
|
59a57fc40a | ||
|
|
50c13e5b6d | ||
|
|
3a38a06302 | ||
|
|
c489002f5c | ||
|
|
7cbc9bd4f5 | ||
|
|
1fd65a4d42 | ||
|
|
b3cc24e4be | ||
|
|
556741ae1e | ||
|
|
b1bcf32535 | ||
|
|
be43d360ba | ||
|
|
0adceb64ec | ||
|
|
5ebef30abb | ||
|
|
3d65afbf8f | ||
|
|
51f01b215c | ||
|
|
c02bf942e4 | ||
|
|
d7025d4e9e | ||
|
|
56f7b4bbc5 | ||
|
|
4a3ebc422f | ||
|
|
4b18302ec7 | ||
|
|
5dd7609524 | ||
|
|
48855c7bb8 | ||
|
|
7a8761f703 | ||
|
|
1dd91a2848 | ||
|
|
90f1c015d2 | ||
|
|
726e20ed52 | ||
|
|
7f750e22cb | ||
|
|
6b6573ddd2 | ||
|
|
8849b363c7 | ||
|
|
005d6b30c0 | ||
|
|
c3436fd23f | ||
|
|
5497e343c0 | ||
|
|
479a2c363a | ||
|
|
18b1e5222e | ||
|
|
d4b4981e26 | ||
|
|
e1f3785bc6 | ||
|
|
e92f261faf | ||
|
|
ea035fa931 | ||
|
|
8167e8e3cb | ||
|
|
bcc16e1ab6 | ||
|
|
28948485f6 | ||
|
|
0985a99ed3 | ||
|
|
1d7084b555 | ||
|
|
64563bbd3a | ||
|
|
0bdeffe70d | ||
|
|
9e0772a9dd | ||
|
|
70e56df80c | ||
|
|
b4756666b6 | ||
|
|
74304bfe17 | ||
|
|
25daa57a6f | ||
|
|
a3ccdc2a46 | ||
|
|
ffefd519b6 | ||
|
|
4823d6d671 | ||
|
|
9b5d5c982b | ||
|
|
a03c83450d | ||
|
|
5d97161c9b | ||
|
|
671c17376a | ||
|
|
1856a5d7ce | ||
|
|
9cb78429e3 | ||
|
|
2b17692fd6 | ||
|
|
19e69f2079 | ||
|
|
72b90655e6 | ||
|
|
cdf83dad37 | ||
|
|
e6be7a659f | ||
|
|
34052368d8 | ||
|
|
08c695bf05 | ||
|
|
d7221974ed | ||
|
|
9a31835b1c | ||
|
|
4a49c8d6c2 | ||
|
|
c493fbd349 | ||
|
|
71c57fc00d | ||
|
|
3044ff70aa | ||
|
|
76a47fd017 | ||
|
|
621a8c419b | ||
|
|
c4cf0dc07b | ||
|
|
9216605106 | ||
|
|
d802d67e22 | ||
|
|
f95b853248 | ||
|
|
3bcecd4fd6 | ||
|
|
b93b63024c | ||
|
|
d9610b26bb | ||
|
|
7c43bcc558 | ||
|
|
30f9b117cc | ||
|
|
f2255e1d53 | ||
|
|
e2bfa8c56b | ||
|
|
5cc29fc58a | ||
|
|
6f02669832 | ||
|
|
e11d511971 | ||
|
|
00eeb0e6d5 | ||
|
|
814d19e698 | ||
|
|
86252f9117 | ||
|
|
b5334887e8 | ||
|
|
6b863164a4 | ||
|
|
b4e58eeb44 | ||
|
|
a246046f41 | ||
|
|
0552bcab4a | ||
|
|
3681507582 | ||
|
|
364f372a8b | ||
|
|
857309c451 | ||
|
|
ad24dd54c7 | ||
|
|
952cb52b95 | ||
|
|
1ad8e892b6 | ||
|
|
4cffbe7b40 | ||
|
|
e6eb90861e | ||
|
|
9005121b52 | ||
|
|
a7c04c2bf7 | ||
|
|
39f8f2e475 | ||
|
|
9c6c95a249 | ||
|
|
aafcb2e94a | ||
|
|
e40fa6e0fb | ||
|
|
c9fd66bd63 | ||
|
|
99cf0eca7a | ||
|
|
2b2476b4bc | ||
|
|
81834ca0db | ||
|
|
0a4a06a50a | ||
|
|
40795bf5c0 | ||
|
|
40034fe400 | ||
|
|
b13c6b0e6f | ||
|
|
c33ea84c77 | ||
|
|
0c748ebe73 | ||
|
|
fda96ac653 | ||
|
|
597c572f24 | ||
|
|
71b0912494 | ||
|
|
f853019f47 | ||
|
|
0b212fc6bd | ||
|
|
3fbffc4a72 | ||
|
|
b17c39c370 | ||
|
|
019dde6ef9 | ||
|
|
34b88c3ad8 | ||
|
|
a1d85c4c9b | ||
|
|
8c12300be3 | ||
|
|
21d1020662 | ||
|
|
80a54de015 | ||
|
|
a3a4a72012 |
8
.github/CODEOWNERS
vendored
Normal file
8
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# See https://docs.github.com/de/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
||||
# For combination with "Require review from code owners" for main-ose branch.
|
||||
|
||||
# Dependabot
|
||||
gradle/** @bitfireAT/app-dev
|
||||
|
||||
# everything else
|
||||
* @rfc2822
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,4 +1,4 @@
|
||||
blank_issues_enabled: false
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: DAVx⁵ Community Support
|
||||
url: https://github.com/bitfireAT/davx5-ose/discussions
|
||||
|
||||
44
.github/ISSUE_TEMPLATE/qualified-bug.yml
vendored
Normal file
44
.github/ISSUE_TEMPLATE/qualified-bug.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Qualified Bug Report
|
||||
description: "For qualified bug reports. (Use Discussions if unsure.)"
|
||||
type: bug
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Problem scope
|
||||
description: Use Discussions if you're unsure which component (DAVx⁵, calendar app, server, …) causes your problem.
|
||||
options:
|
||||
- label: I'm sure that this is a DAVx⁵ problem.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: App version
|
||||
options:
|
||||
- label: I'm using the latest available DAVx⁵ version.
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Android version and device/firmware type
|
||||
placeholder: "Android 13 (Samsung A32)"
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Provide detailed steps to reproduce the problem.
|
||||
placeholder: |
|
||||
1. Create DAVx⁵ account with Some Server (Version).
|
||||
2. Sync Some Calendar.
|
||||
3. SomeException appears.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Actual result
|
||||
description: Describe what you DAVx⁵ currently does (and what is not expected).
|
||||
placeholder: "Some Property in ICS file causes the whole synchronization to stop."
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected result
|
||||
description: Describe what you would expect DAVx⁵ to avoid/solve the problem.
|
||||
placeholder: "Some Property in ICS file should be ignored even if faulty and sync should continue instead of showing an error."
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Further info
|
||||
description: Debug info, links to further information, …
|
||||
20
.github/ISSUE_TEMPLATE/qualified-feature.yml
vendored
Normal file
20
.github/ISSUE_TEMPLATE/qualified-feature.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
name: Qualified Feature Request
|
||||
description: "For qualified feature requests. (Use Discussions if unsure.)"
|
||||
type: feature
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Scope
|
||||
description: Use this form only for features that have been discussed in Discussions or if you're a DAVx5 developer.
|
||||
options:
|
||||
- label: I'm sure that this feature request belongs here and not into Discussions.
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: Describe the requested feature and why it is desired.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Further info
|
||||
description: How this could be implemented, links to further information, …
|
||||
32
.github/dependabot.yml
vendored
Normal file
32
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Enable version updates for GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
# Workflow files stored in the default location of `.github/workflows`
|
||||
# You don't need to specify `/.github/workflows` for `directory`. You can use `directory: "/"`.
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "[CI] "
|
||||
labels:
|
||||
- "github_actions"
|
||||
- "dependencies"
|
||||
groups:
|
||||
ci-actions:
|
||||
patterns: ["*"]
|
||||
|
||||
- package-ecosystem: "gradle"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
labels: # don't create "java" label (default for gradle ecosystem)
|
||||
- "dependencies"
|
||||
groups:
|
||||
app-dependencies:
|
||||
patterns: ["*"]
|
||||
ignore:
|
||||
# dependencies without semantic versioning
|
||||
- dependency-name: "com.github.bitfireat:cert4android"
|
||||
- dependency-name: "com.github.bitfireat:dav4jvm"
|
||||
- dependency-name: "com.github.bitfireat:synctools"
|
||||
38
.github/pull_request_template.md
vendored
Normal file
38
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
|
||||
Please delete this paragraph and other repeating text (like the examples) after reading and before submitting the PR.
|
||||
|
||||
The PR should be in _Draft_ state during development. As soon as it's finished, it should be marked as _Ready for review_ and a reviewer should be chosen.
|
||||
|
||||
See also: [Writing A Great Pull Request Description](https://www.pullrequest.com/blog/writing-a-great-pull-request-description/)
|
||||
|
||||
|
||||
### Purpose
|
||||
|
||||
What this PR is intended to do and why this is desirable.
|
||||
|
||||
Example:
|
||||
|
||||
> Adds support for AAA in BBB, as requested by several people in issue #XX.
|
||||
|
||||
|
||||
### Short description
|
||||
|
||||
A short description of the chosen approach to achieve the purpose.
|
||||
|
||||
Example:
|
||||
|
||||
> - Added authentication option _Some authentication_ to _some module_.
|
||||
> - Added support for _Some authentication_ to _some content provider_.
|
||||
> - Added UI support for _Some authentication_ in account settings.
|
||||
|
||||
Related information (links to Android docs and other resources that help to understand/review
|
||||
the changes) can also be put here.
|
||||
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] The PR has a proper title, description and label.
|
||||
- [ ] I have [self-reviewed the PR](https://patrickdinh.medium.com/review-your-own-pull-requests-5634cad10b7a).
|
||||
- [ ] I have added documentation to complex functions and functions that can be used by other modules.
|
||||
- [ ] I have added reasonable tests or consciously decided to not add tests.
|
||||
|
||||
20
.github/release.yml
vendored
Normal file
20
.github/release.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- ignore-for-release
|
||||
categories:
|
||||
- title: New features
|
||||
labels:
|
||||
- enhancement
|
||||
- title: Bug fixes
|
||||
labels:
|
||||
- bug
|
||||
- title: Refactoring
|
||||
labels:
|
||||
- refactoring
|
||||
- title: Dependencies
|
||||
labels:
|
||||
- dependencies
|
||||
- title: Other changes
|
||||
labels:
|
||||
- "*"
|
||||
37
.github/workflows/codeql.yml
vendored
37
.github/workflows/codeql.yml
vendored
@@ -8,6 +8,7 @@ on:
|
||||
branches: [ main-ose ]
|
||||
schedule:
|
||||
- cron: '22 10 * * 1'
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -21,45 +22,29 @@ jobs:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'java' ]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
- uses: gradle/actions/setup-gradle@v3
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: true # gradle user home cache is generated by test jobs
|
||||
|
||||
- name: Use incremental build cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
key: incremental-build-tests
|
||||
restore-keys: incremental-build-tests # restore cache from main branch
|
||||
path: app/build
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
languages: java-kotlin
|
||||
build-mode: manual # autobuild uses older JDK
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
#- name: Autobuild
|
||||
# uses: github/codeql-action/autobuild@v2
|
||||
|
||||
- name: Build
|
||||
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn --no-daemon app:assembleOseDebug
|
||||
- name: Build # we must not use build cache here
|
||||
run: ./gradlew --no-daemon --configuration-cache app:assembleDebug
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
24
.github/workflows/dependency-submission.yml
vendored
Normal file
24
.github/workflows/dependency-submission.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: Dependency Submission
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ 'main-ose' ]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
dependency-submission:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Generate and submit dependency graph
|
||||
uses: gradle/actions/dependency-submission@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
dependency-graph-exclude-configurations: '.*[Tt]est.* .*[cC]heck.*'
|
||||
55
.github/workflows/dependent-issues.yml
vendored
55
.github/workflows/dependent-issues.yml
vendored
@@ -1,55 +0,0 @@
|
||||
name: Dependent Issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- closed
|
||||
- reopened
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- closed
|
||||
- reopened
|
||||
# Makes sure we always add status check for PRs. Useful only if
|
||||
# this action is required to pass before merging. Otherwise, it
|
||||
# can be removed.
|
||||
- synchronize
|
||||
|
||||
# Schedule a daily check. Useful if you reference cross-repository
|
||||
# issues or pull requests. Otherwise, it can be removed.
|
||||
schedule:
|
||||
- cron: '19 9 * * *'
|
||||
|
||||
permissions: write-all
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: z0al/dependent-issues@v1
|
||||
env:
|
||||
# (Required) The token to use to make API calls to GitHub.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# (Optional) The token to use to make API calls to GitHub for remote repos.
|
||||
GITHUB_READ_TOKEN: ${{ secrets.DEPENDENT_ISSUES_READ_TOKEN }}
|
||||
|
||||
with:
|
||||
# (Optional) The label to use to mark dependent issues
|
||||
# label: dependent
|
||||
|
||||
# (Optional) Enable checking for dependencies in issues.
|
||||
# Enable by setting the value to "on". Default "off"
|
||||
check_issues: on
|
||||
|
||||
# (Optional) A comma-separated list of keywords. Default
|
||||
# "depends on, blocked by"
|
||||
keywords: depends on, blocked by
|
||||
|
||||
# (Optional) A custom comment body. It supports `{{ dependencies }}` token.
|
||||
comment: >
|
||||
This PR/issue depends on:
|
||||
|
||||
{{ dependencies }}
|
||||
15
.github/workflows/release.yml
vendored
15
.github/workflows/release.yml
vendored
@@ -19,19 +19,19 @@ jobs:
|
||||
discussions: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
- uses: gradle/actions/setup-gradle@v3
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
|
||||
- name: Prepare keystore
|
||||
run: echo ${{ secrets.android_keystore_base64 }} | base64 -d >$GITHUB_WORKSPACE/keystore.jks
|
||||
|
||||
- name: Build signed package
|
||||
# Make sure that caches are disabled to generate reproducible release builds
|
||||
run: ./gradlew --no-build-cache --no-configuration-cache --no-daemon app:assembleRelease
|
||||
# Use build cache to speed up building of build variants, but clean caches from previous tests before
|
||||
run: ./gradlew --build-cache --configuration-cache --no-daemon app:clean app:assembleRelease
|
||||
env:
|
||||
ANDROID_KEYSTORE: ${{ github.workspace }}/keystore.jks
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.android_keystore_password }}
|
||||
@@ -39,10 +39,9 @@ jobs:
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.android_key_password }}
|
||||
|
||||
- name: Create Github release
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
prerelease: ${{ env.prerelease }}
|
||||
files: app/build/outputs/apk/ose/release/*.apk
|
||||
fail_on_unmatched_files: true
|
||||
generate_release_notes: true
|
||||
discussion_category_name: Announcements
|
||||
|
||||
144
.github/workflows/test-dev.yml
vendored
144
.github/workflows/test-dev.yml
vendored
@@ -2,83 +2,135 @@ name: Development tests
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
- 'main-ose'
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: test-dev-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# We provide a remote gradle build cache. Take the settings from the secrets and enable
|
||||
# configuration and build cache for all gradle jobs.
|
||||
#
|
||||
# Note: The secrets are not available for forks and Dependabot PRs.
|
||||
env:
|
||||
is_main_branch: ${{ github.ref == 'refs/heads/main-ose' }}
|
||||
GRADLE_BUILDCACHE_URL: ${{ secrets.gradle_buildcache_url }}
|
||||
GRADLE_BUILDCACHE_USERNAME: ${{ secrets.gradle_buildcache_username }}
|
||||
GRADLE_BUILDCACHE_PASSWORD: ${{ secrets.gradle_buildcache_password }}
|
||||
GRADLE_OPTS: -Dorg.gradle.caching=true -Dorg.gradle.configuration-cache=true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Tests without emulator
|
||||
compile:
|
||||
name: Compile
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
- uses: gradle/actions/setup-gradle@v3
|
||||
java-version: 21
|
||||
|
||||
# See https://community.gradle.org/github-actions/docs/setup-gradle/ for more information
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: true # gradle user home cache is generated by test_on_emulator
|
||||
cache-read-only: false # allow branches to update their configuration cache
|
||||
gradle-home-cache-excludes: caches/build-cache-1 # don't cache local build cache because we use a remote cache
|
||||
|
||||
- name: Use incremental build cache
|
||||
uses: actions/cache/restore@v4
|
||||
- name: Cache Android environment
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
key: incremental-build-tests
|
||||
restore-keys: incremental-build-tests # restore cache from main branch
|
||||
path: app/build
|
||||
path: ~/.config/.android # needs to be cached so that configuration cache can work
|
||||
key: android-${{ hashFiles('app/build.gradle.kts') }}
|
||||
|
||||
- name: Run lint and unit tests
|
||||
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn --no-daemon app:check
|
||||
- name: Compile
|
||||
run: ./gradlew app:compileOseDebugSource
|
||||
|
||||
# generates the build caches because it uses more gradle dependencies
|
||||
test_on_emulator:
|
||||
name: Tests with emulator
|
||||
# Cache configurations for the other jobs (including assemble for CodeQL)
|
||||
- name: Populate configuration cache
|
||||
run: |
|
||||
./gradlew --dry-run app:assembleDebug
|
||||
./gradlew --dry-run app:lintOseDebug
|
||||
./gradlew --dry-run app:testOseDebugUnitTest
|
||||
./gradlew --dry-run app:virtualOseDebugAndroidTest
|
||||
|
||||
unit_tests:
|
||||
needs: compile
|
||||
name: Lint and unit tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
- uses: gradle/actions/setup-gradle@v3
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
gradle-home-cache-cleanup: true # avoid ever-growing gradle user home cache
|
||||
|
||||
- name: Use incremental build cache
|
||||
if: ${{ !env.is_main_branch }}
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
key: incremental-build-tests
|
||||
restore-keys: incremental-build-tests # restore cache from main branch
|
||||
path: |
|
||||
.gradle/configuration-cache
|
||||
app/build
|
||||
cache-read-only: true
|
||||
|
||||
- name: Restore Android environment
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ~/.config/.android
|
||||
key: android-${{ hashFiles('app/build.gradle.kts') }}
|
||||
|
||||
- name: Lint checks
|
||||
run: ./gradlew app:lintOseDebug
|
||||
|
||||
- name: Unit tests
|
||||
run: ./gradlew app:testOseDebugUnitTest
|
||||
|
||||
instrumented_tests:
|
||||
needs: compile
|
||||
name: Instrumented tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-read-only: true
|
||||
|
||||
- name: Restore Android environment
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ~/.config/.android
|
||||
key: android-${{ hashFiles('app/build.gradle.kts') }}
|
||||
|
||||
# gradle and Android SDK often take more space than what is available on the default runner.
|
||||
# We try to free a few GB here to make gradle-managed devices more reliable.
|
||||
- name: Free some disk space
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
android: false # we need the Android SDK
|
||||
large-packages: false # apt takes too long
|
||||
swap-storage: false # gradle needs much memory
|
||||
|
||||
- name: Restore AVD
|
||||
id: restore-avd
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ~/.config/.android/avd # where AVD is stored
|
||||
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there
|
||||
|
||||
# Enable virtualization for Android emulator
|
||||
- name: Enable KVM group perms
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Instrumented tests
|
||||
run: ./gradlew app:virtualOseDebugAndroidTest
|
||||
|
||||
- name: Cache AVD
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache/save@v5
|
||||
if: steps.restore-avd.outputs.cache-hit != 'true'
|
||||
with:
|
||||
path: ~/.config/.android/avd
|
||||
path: ~/.config/.android/avd # where AVD is stored
|
||||
key: avd-${{ hashFiles('app/build.gradle.kts') }} # gradle-managed devices are defined there
|
||||
|
||||
- name: Run device tests
|
||||
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn --no-daemon app:virtualCheck
|
||||
|
||||
- name: Create incremental build cache
|
||||
if: ${{ env.is_main_branch }}
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
key: incremental-build-tests-${{ github.run_id }}
|
||||
path: app/build
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -8,17 +8,14 @@
|
||||
# Files for the Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
# Java/Kotlin
|
||||
*.class
|
||||
.kotlin/
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
|
||||
9
.idea/codeStyles/Project.xml
generated
Normal file
9
.idea/codeStyles/Project.xml
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<option name="LINE_SEPARATOR" value=" " />
|
||||
<option name="RIGHT_MARGIN" value="180" />
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
6
.idea/copyright/LICENSE.xml
generated
Normal file
6
.idea/copyright/LICENSE.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<component name="CopyrightManager">
|
||||
<copyright>
|
||||
<option name="notice" value="Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details." />
|
||||
<option name="myName" value="LICENSE" />
|
||||
</copyright>
|
||||
</component>
|
||||
3
.idea/copyright/profiles_settings.xml
generated
Normal file
3
.idea/copyright/profiles_settings.xml
generated
Normal file
@@ -0,0 +1,3 @@
|
||||
<component name="CopyrightManager">
|
||||
<settings default="LICENSE" />
|
||||
</component>
|
||||
30
.tx/config
30
.tx/config
@@ -1,30 +0,0 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
lang_map = ar_SA: ar, en_GB: en-rGB, fa_IR: fa-rIR, fi_FI: fi, nb_NO: nb, sk_SK: sk, sl_SI: sl, tr_TR: tr, zh_CN: zh, zh_TW: zh-rTW
|
||||
|
||||
[o:bitfireAT:p:davx5:r:app]
|
||||
file_filter = app/src/main/res/values-<lang>/strings.xml
|
||||
source_file = app/src/main/res/values/strings.xml
|
||||
source_lang = en
|
||||
type = ANDROID
|
||||
minimum_perc = 20
|
||||
resource_name = App strings (all flavors)
|
||||
|
||||
|
||||
# Attention: fastlane directories are like "en-us", not "en-rUS"!
|
||||
|
||||
[o:bitfireAT:p:davx5:r:metadata-short-description]
|
||||
file_filter = fastlane/metadata/android/<lang>/short_description.txt
|
||||
source_file = fastlane/metadata/android/en-US/short_description.txt
|
||||
source_lang = en
|
||||
type = TXT
|
||||
minimum_perc = 100
|
||||
resource_name = Metadata: short description
|
||||
|
||||
[o:bitfireAT:p:davx5:r:metadata-full-description]
|
||||
file_filter = fastlane/metadata/android/<lang>/full_description.txt
|
||||
source_file = fastlane/metadata/android/en-US/full_description.txt
|
||||
source_lang = en
|
||||
type = TXT
|
||||
minimum_perc = 100
|
||||
resource_name = Metadata: full description
|
||||
14
AUTHORS
14
AUTHORS
@@ -1,11 +1,7 @@
|
||||
# This is the list of significant contributors to DAVx5.
|
||||
#
|
||||
# This does not necessarily list everyone who has contributed work.
|
||||
# To see the full list of contributors, see the revision history in
|
||||
# source control.
|
||||
You can view the list of people who have contributed to the code base in the version control history:
|
||||
https://github.com/bitfireAT/davx5-ose/graphs/contributors
|
||||
|
||||
Ricki Hirner (bitfire.at)
|
||||
Bernhard Stockmann (bitfire.at)
|
||||
Translators are not mentioned in the history explicitly.
|
||||
The list of translators can be found in the About screen.
|
||||
|
||||
Sunik Kupfer (bitfire.at)
|
||||
Patrick Lang (techbee.at)
|
||||
Every contribution is welcome. There are many other forms of contributing besides writing code!
|
||||
|
||||
@@ -14,24 +14,11 @@ If you send us a pull request, our CLA bot will ask you to sign the
|
||||
Contributor's License Agreement so that we can use your contribution.
|
||||
|
||||
|
||||
# Copyright
|
||||
# Copyright notice
|
||||
|
||||
Make sure that every file that contains significant work (at least every code file)
|
||||
starts with the copyright header:
|
||||
|
||||
```
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
```
|
||||
|
||||
You can set this in Android Studio:
|
||||
|
||||
1. Settings / Editor / Copyright / Copyright Profiles
|
||||
2. Paste the text above (without the stars).
|
||||
3. Set Formatting so that the preview exactly looks like above; one blank line after the block.
|
||||
4. Set this copyright profile as the default profile for the project.
|
||||
5. Apply copyright: right-click in file tree / Update copyright.
|
||||
starts with the copyright header. Android Studio should do so automatically because the
|
||||
configuration is stored in the repository (`.idea/copyright`).
|
||||
|
||||
|
||||
# Style guide
|
||||
@@ -110,8 +97,3 @@ Test classes should be in the appropriate directory (see existing tests) and in
|
||||
tested class. Tests are usually be named like `methodToBeTested_Condition()`, see
|
||||
[Test apps on Android](https://developer.android.com/training/testing/).
|
||||
|
||||
|
||||
# Authors
|
||||
|
||||
If you make significant contributions, feel free to add yourself to the [AUTHORS file](AUTHORS).
|
||||
|
||||
|
||||
17
README.md
17
README.md
@@ -1,9 +1,9 @@
|
||||
|
||||
[](https://www.davx5.com/)
|
||||
[](https://f-droid.org/packages/at.bitfire.davdroid/)
|
||||
[](https://github.com/bitfireAT/davx5-ose/blob/main/LICENSE)
|
||||
[](https://fosstodon.org/@davx5app)
|
||||
[](https://github.com/bitfireAT/davx5-ose/actions/workflows/test-dev.yml)
|
||||
[](https://www.davx5.com/)
|
||||
[](https://github.com/bitfireAT/davx5-ose/blob/main/LICENSE)
|
||||
[](https://f-droid.org/packages/at.bitfire.davdroid/)
|
||||

|
||||
|
||||

|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
DAVx⁵
|
||||
========
|
||||
|
||||
Please see the [DAVx⁵ Web site](https://www.davx5.com) for
|
||||
comprehensive information about DAVx⁵, including a list of services it has been tested with.
|
||||
> [!IMPORTANT]
|
||||
> Please see the [DAVx⁵ Web site](https://www.davx5.com) for
|
||||
> comprehensive information about DAVx⁵, including a list of services it has been tested with,
|
||||
> a manual and FAQ.
|
||||
|
||||
DAVx⁵ is licensed under the [GPLv3 License](LICENSE).
|
||||
|
||||
@@ -26,8 +28,7 @@ Parts of DAVx⁵ have been outsourced into these libraries:
|
||||
|
||||
* [cert4android](https://github.com/bitfireAT/cert4android) – custom certificate management
|
||||
* [dav4jvm](https://github.com/bitfireAT/dav4jvm) – WebDAV/CalDav/CardDAV framework
|
||||
* [ical4android](https://github.com/bitfireAT/ical4android) – iCalendar processing and Calendar Provider access
|
||||
* [vcard4android](https://github.com/bitfireAT/vcard4android) – vCard processing and Contacts Provider access
|
||||
* [synctools](https://github.com/bitfireAT/synctools) – iCalendar/vCard/Tasks processing and content provider access
|
||||
|
||||
**If you want to support DAVx⁵, please consider [donating to DAVx⁵](https://www.davx5.com/donate)
|
||||
or [purchasing it](https://www.davx5.com/download).**
|
||||
|
||||
@@ -1,55 +1,54 @@
|
||||
/***************************************************************************************************
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
**************************************************************************************************/
|
||||
*/
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.mikepenz.aboutLibraries)
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.mikepenz.aboutLibraries.android)
|
||||
}
|
||||
|
||||
// Android configuration
|
||||
android {
|
||||
compileSdk = 34
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "at.bitfire.davdroid"
|
||||
|
||||
versionCode = 403160005
|
||||
versionName = "4.3.16"
|
||||
versionCode = 405090005
|
||||
versionName = "4.5.9"
|
||||
|
||||
buildConfigField("long", "buildTime", "${System.currentTimeMillis()}L")
|
||||
|
||||
setProperty("archivesBaseName", "davx5-ose-" + versionName)
|
||||
base.archivesName = "davx5-$versionCode-$versionName"
|
||||
|
||||
minSdk = 24 // Android 7.0
|
||||
targetSdk = 34 // Android 14
|
||||
targetSdk = 36 // Android 16
|
||||
|
||||
buildConfigField("String", "userAgent", "\"DAVx5\"")
|
||||
testInstrumentationRunner = "at.bitfire.davdroid.HiltTestRunner"
|
||||
}
|
||||
|
||||
testInstrumentationRunner = "at.bitfire.davdroid.CustomTestRunner"
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
// enable because ical4android requires desugaring
|
||||
// required for
|
||||
// - dnsjava 3.x: java.nio.file.Path
|
||||
// - ical4android: time API
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
compose = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
|
||||
}
|
||||
|
||||
// Java namespace for our classes (not to be confused with Android package ID)
|
||||
namespace = "at.bitfire.davdroid"
|
||||
namespace = "com.davx5.ose"
|
||||
|
||||
flavorDimensions += "distribution"
|
||||
productFlavors {
|
||||
@@ -86,25 +85,28 @@ android {
|
||||
}
|
||||
|
||||
lint {
|
||||
disable += arrayOf("GoogleAppIndexingWarning", "ImpliedQuantity", "MissingQuantity", "MissingTranslation", "ExtraTranslation", "RtlEnabled", "RtlHardcoded", "Typos", "NullSafeMutableLiveData")
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += arrayOf("META-INF/*.md")
|
||||
}
|
||||
disable += arrayOf("GoogleAppIndexingWarning", "ImpliedQuantity", "MissingQuantity", "MissingTranslation", "ExtraTranslation", "RtlEnabled", "RtlHardcoded", "Typos")
|
||||
}
|
||||
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
// multiple (test) dependencies have LICENSE files at same location
|
||||
merges += arrayOf("META-INF/LICENSE*")
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
testOptions {
|
||||
managedDevices {
|
||||
localDevices {
|
||||
create("virtual") {
|
||||
device = "Pixel 3"
|
||||
// TBD: API level 35 and higher causes network tests to fail sometimes, see https://github.com/bitfireAT/davx5-ose/issues/1525
|
||||
// Suspected reason: https://developer.android.com/about/versions/15/behavior-changes-all#background-network-access
|
||||
apiLevel = 34
|
||||
systemImageSource = "aosp-atd"
|
||||
}
|
||||
@@ -117,19 +119,18 @@ ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
|
||||
configurations {
|
||||
configureEach {
|
||||
// exclude modules which are in conflict with system libraries
|
||||
exclude(module="commons-logging")
|
||||
exclude(group="org.json", module="json")
|
||||
|
||||
// Groovy requires SDK 26+, and it's not required, so exclude it
|
||||
exclude(group="org.codehaus.groovy")
|
||||
aboutLibraries {
|
||||
export {
|
||||
// exclude timestamps for reproducible builds [https://github.com/bitfireAT/davx5-ose/issues/994]
|
||||
excludeFields.add("generated")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// core
|
||||
// app core
|
||||
implementation(project(":core"))
|
||||
|
||||
// Kotlin / Android
|
||||
implementation(libs.kotlin.stdlib)
|
||||
implementation(libs.kotlinx.coroutines)
|
||||
coreLibraryDesugaring(libs.android.desugaring)
|
||||
@@ -143,11 +144,8 @@ dependencies {
|
||||
implementation(libs.androidx.activityCompose)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.browser)
|
||||
implementation(libs.androidx.cardView)
|
||||
implementation(libs.androidx.concurrentFuture)
|
||||
implementation(libs.androidx.constraintLayout)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.fragment)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
implementation(libs.androidx.hilt.work)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.base)
|
||||
@@ -156,56 +154,66 @@ dependencies {
|
||||
implementation(libs.androidx.paging.compose)
|
||||
implementation(libs.androidx.preference)
|
||||
implementation(libs.androidx.security)
|
||||
implementation(libs.androidx.swiperefreshlayout)
|
||||
implementation(libs.androidx.work.base)
|
||||
implementation(libs.android.flexbox)
|
||||
implementation(libs.android.material)
|
||||
|
||||
// Jetpack Compose
|
||||
implementation(libs.compose.accompanist.permissions)
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.material)
|
||||
implementation(libs.compose.materialIconsExtended)
|
||||
implementation(libs.compose.runtime.livedata)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
implementation(libs.compose.ui.toolingPreview)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material3.adaptive)
|
||||
implementation(libs.androidx.compose.materialIconsExtended)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
implementation(libs.androidx.compose.ui.toolingPreview)
|
||||
|
||||
// Glance Widgets
|
||||
implementation(libs.glance.base)
|
||||
implementation(libs.glance.material)
|
||||
implementation(libs.androidx.glance.base)
|
||||
implementation(libs.androidx.glance.material3)
|
||||
|
||||
// Jetpack Room
|
||||
implementation(libs.room.runtime)
|
||||
implementation(libs.room.base)
|
||||
implementation(libs.room.paging)
|
||||
ksp(libs.room.compiler)
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.base)
|
||||
implementation(libs.androidx.room.paging)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
|
||||
// own libraries
|
||||
implementation(libs.bitfire.cert4android)
|
||||
implementation(libs.bitfire.dav4jvm) {
|
||||
exclude(group="junit")
|
||||
exclude(group="org.ogce", module="xpp3") // Android has its own XmlPullParser implementation
|
||||
}
|
||||
implementation(libs.bitfire.synctools) {
|
||||
exclude(group="androidx.test") // synctools declares test rules, but we don't want them in non-test code
|
||||
exclude(group = "junit")
|
||||
}
|
||||
implementation(libs.bitfire.ical4android)
|
||||
implementation(libs.bitfire.vcard4android)
|
||||
|
||||
// third-party libs
|
||||
implementation(libs.appintro)
|
||||
implementation(libs.commons.collections)
|
||||
@Suppress("RedundantSuppression")
|
||||
implementation(libs.commons.io)
|
||||
implementation(libs.commons.lang)
|
||||
implementation(libs.commons.text)
|
||||
@Suppress("RedundantSuppression")
|
||||
implementation(libs.conscrypt)
|
||||
implementation(libs.dnsjava)
|
||||
implementation(libs.mikepenz.aboutLibraries)
|
||||
implementation(libs.nsk90.kstatemachine)
|
||||
implementation(libs.guava)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
implementation(libs.mikepenz.aboutLibraries.m3)
|
||||
implementation(libs.okhttp.base)
|
||||
implementation(libs.okhttp.brotli)
|
||||
implementation(libs.okhttp.logging)
|
||||
implementation(libs.openid.appauth)
|
||||
implementation(libs.unifiedpush) {
|
||||
// UnifiedPush connector seems to be using a workaround by importing this library.
|
||||
// Will be removed after https://github.com/tink-crypto/tink-java-apps/pull/5 is merged.
|
||||
// See: https://codeberg.org/UnifiedPush/android-connector/src/commit/28cb0d622ed0a972996041ab9cc85b701abc48c6/connector/build.gradle#L56-L59
|
||||
exclude(group = "com.google.crypto.tink", module = "tink")
|
||||
}
|
||||
implementation(libs.unifiedpush.fcm)
|
||||
|
||||
// force some versions for compatibility with our minSdk level (see version catalog for details)
|
||||
implementation(libs.commons.codec)
|
||||
implementation(libs.commons.lang)
|
||||
|
||||
// for tests
|
||||
androidTestImplementation(libs.androidx.arch.core.testing)
|
||||
androidTestImplementation(libs.androidx.room.testing)
|
||||
androidTestImplementation(libs.androidx.test.core)
|
||||
androidTestImplementation(libs.androidx.test.junit)
|
||||
androidTestImplementation(libs.androidx.test.rules)
|
||||
@@ -213,10 +221,13 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.work.testing)
|
||||
androidTestImplementation(libs.hilt.android.testing)
|
||||
androidTestImplementation(libs.junit)
|
||||
androidTestImplementation(libs.kotlinx.coroutines.test)
|
||||
androidTestImplementation(libs.mockk.android)
|
||||
androidTestImplementation(libs.okhttp.mockwebserver)
|
||||
androidTestImplementation(libs.room.testing)
|
||||
|
||||
testImplementation(libs.bitfire.dav4jvm)
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
}
|
||||
testImplementation(libs.robolectric)
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
|
||||
# R8 usage for DAVx⁵:
|
||||
# shrinking yes (only in release builds)
|
||||
# optimization yes (on by R8 defaults)
|
||||
# full-mode no (see gradle.properties)
|
||||
# obfuscation no (open-source)
|
||||
|
||||
-dontobfuscate
|
||||
-printusage build/reports/r8-usage.txt
|
||||
|
||||
# ez-vcard: keep all vCard properties/parameters (used via reflection)
|
||||
-keep class ezvcard.io.scribe.** { *; }
|
||||
-keep class ezvcard.property.** { *; }
|
||||
-keep class ezvcard.parameter.** { *; }
|
||||
|
||||
# ical4j: keep all iCalendar properties/parameters (used via reflection)
|
||||
-keep class net.fortuna.ical4j.** { *; }
|
||||
|
||||
# XmlPullParser
|
||||
-keep class org.xmlpull.** { *; }
|
||||
|
||||
# DAVx⁵ + libs
|
||||
-keep class at.bitfire.** { *; } # all DAVx⁵ code is required
|
||||
|
||||
# AGP 8.2 and 8.3 seem to remove this class, but ezvcard.io uses it. See https://github.com/bitfireAT/davx5/issues/499
|
||||
-keep class javax.xml.namespace.QName { *; }
|
||||
|
||||
# we use enum classes (https://www.guardsquare.com/en/products/proguard/manual/examples#enumerations)
|
||||
-keepclassmembers,allowoptimization enum * {
|
||||
public static **[] values();
|
||||
public static ** valueOf(java.lang.String);
|
||||
}
|
||||
|
||||
# Additional rules which are now required since missing classes can't be ignored in R8 anymore.
|
||||
# [https://developer.android.com/build/releases/past-releases/agp-7-0-0-release-notes#r8-missing-class-warning]
|
||||
-dontwarn com.android.org.conscrypt.SSLParametersImpl
|
||||
-dontwarn com.github.erosb.jsonsKema.** # ical4j
|
||||
-dontwarn com.sun.jna.** # dnsjava
|
||||
-dontwarn groovy.**
|
||||
-dontwarn java.beans.Transient
|
||||
-dontwarn javax.cache.** # ical4j
|
||||
-dontwarn javax.naming.NamingException # dnsjava
|
||||
-dontwarn javax.naming.directory.** # dnsjava
|
||||
-dontwarn junit.textui.TestRunner
|
||||
-dontwarn lombok.** # dnsjava
|
||||
-dontwarn org.apache.harmony.xnet.provider.jsse.SSLParametersImpl
|
||||
-dontwarn org.bouncycastle.jsse.**
|
||||
-dontwarn org.codehaus.groovy.**
|
||||
-dontwarn org.joda.**
|
||||
-dontwarn org.jparsec.** # ical4j
|
||||
-dontwarn org.json.*
|
||||
-dontwarn org.jsoup.**
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
|
||||
-dontwarn org.openjsse.net.ssl.OpenJSSE
|
||||
-dontwarn org.xmlpull.**
|
||||
-dontwarn sun.net.spi.nameservice.NameService
|
||||
-dontwarn sun.net.spi.nameservice.NameServiceDescriptor
|
||||
1
app/src/.gitignore
vendored
1
app/src/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
espressoTest
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.runner.AndroidJUnitRunner
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import dagger.hilt.android.testing.HiltTestApplication
|
||||
|
||||
class CustomTestRunner : AndroidJUnitRunner() {
|
||||
|
||||
override fun newApplication(cl: ClassLoader, name: String, context: Context) =
|
||||
super.newApplication(cl, HiltTestApplication::class.java.name, context)
|
||||
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.os.Build
|
||||
import android.provider.CalendarContract
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.resource.LocalCalendar
|
||||
import at.bitfire.davdroid.resource.LocalEvent
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.Event
|
||||
import net.fortuna.ical4j.model.property.DtStart
|
||||
import net.fortuna.ical4j.model.property.RRule
|
||||
import org.junit.rules.RuleChain
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.Description
|
||||
import org.junit.runners.model.Statement
|
||||
|
||||
/**
|
||||
* JUnit ClassRule which initializes the AOSP CalendarProvider.
|
||||
* Needed for some "flaky" tests which would otherwise only succeed on second run.
|
||||
*
|
||||
* Currently tested on development machine (Ryzen) with Android 12 images (with/without Google Play).
|
||||
* Calendar provider behaves quite randomly, so it may or may not work. If you (the reader
|
||||
* if this comment) can find out on how to initialize the calendar provider so that the
|
||||
* tests are reliably run after `adb shell pm clear com.android.providers.calendar`,
|
||||
* please let us know!
|
||||
*
|
||||
* If you run tests manually, just make sure to ignore the first run after the calendar
|
||||
* provider has been accessed the first time.
|
||||
*/
|
||||
class InitCalendarProviderRule private constructor(): TestRule {
|
||||
|
||||
companion object {
|
||||
fun getInstance() = RuleChain
|
||||
.outerRule(GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR))
|
||||
.around(InitCalendarProviderRule())
|
||||
}
|
||||
|
||||
override fun apply(base: Statement, description: Description): Statement {
|
||||
Logger.log.info("Initializing calendar provider before running ${description.displayName}")
|
||||
return InitCalendarProviderStatement(base)
|
||||
}
|
||||
|
||||
|
||||
class InitCalendarProviderStatement(val base: Statement): Statement() {
|
||||
|
||||
override fun evaluate() {
|
||||
if (Build.VERSION.SDK_INT < 31)
|
||||
Logger.log.warning("Calendar provider initialization may or may not work. See InitCalendarProviderRule")
|
||||
initCalendarProvider()
|
||||
|
||||
base.evaluate()
|
||||
}
|
||||
|
||||
private fun initCalendarProvider() {
|
||||
val account = Account("LocalCalendarTest", CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
|
||||
val uri = AndroidCalendar.create(account, provider, ContentValues())
|
||||
val calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
|
||||
try {
|
||||
// single event init
|
||||
val normalEvent = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 1 instance"
|
||||
}
|
||||
val normalLocalEvent = LocalEvent(calendar, normalEvent, null, null, null, 0)
|
||||
normalLocalEvent.add()
|
||||
LocalEvent.numInstances(provider, account, normalLocalEvent.id!!)
|
||||
|
||||
// recurring event init
|
||||
val recurringEvent = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event over 22 years"
|
||||
rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year needs to be >2074 (not supported by Android <11 Calendar Storage)
|
||||
}
|
||||
val localRecurringEvent = LocalEvent(calendar, recurringEvent, null, null, null, 0)
|
||||
localRecurringEvent.add()
|
||||
LocalEvent.numInstances(provider, account, localRecurringEvent.id!!)
|
||||
} finally {
|
||||
calendar.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.hilt.testing.TestInstallIn
|
||||
import io.mockk.spyk
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@TestInstallIn(
|
||||
components = [ SingletonComponent::class ],
|
||||
replaces = [
|
||||
SettingsManager.SettingsManagerModule::class
|
||||
]
|
||||
)
|
||||
class MockingModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun spykSettingsManager(@ApplicationContext context: Context): SettingsManager =
|
||||
spyk(SettingsManager(context))
|
||||
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class AppDatabaseTest {
|
||||
|
||||
val TEST_DB = "test"
|
||||
|
||||
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
@Rule
|
||||
@JvmField
|
||||
val helper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
AppDatabase::class.java,
|
||||
listOf(), // no auto migrations until v8
|
||||
FrameworkSQLiteOpenHelperFactory()
|
||||
)
|
||||
|
||||
|
||||
@Test
|
||||
fun testAllMigrations() {
|
||||
// DB schema is available since version 8, so create DB with v8
|
||||
helper.createDatabase(TEST_DB, 8).close()
|
||||
|
||||
val db = Room.databaseBuilder(context, AppDatabase::class.java, TEST_DB)
|
||||
// manual migrations
|
||||
.addMigrations(*AppDatabase.migrations)
|
||||
// auto-migrations that need to be specified explicitly
|
||||
.addAutoMigrationSpec(AppDatabase.AutoMigration11_12(context))
|
||||
.build()
|
||||
try {
|
||||
// open (with version 8) + migrate (to current version) database
|
||||
db.openHelper.writableDatabase
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import androidx.test.filters.SmallTest
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class CollectionTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var settingsManager: SettingsManager
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
private lateinit var httpClient: HttpClient
|
||||
private val server = MockWebServer()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
httpClient = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build()
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
}
|
||||
|
||||
@After
|
||||
fun shutDown() {
|
||||
httpClient.close()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testFromDavResponseAddressBook() {
|
||||
// r/w address book
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
|
||||
"<response>" +
|
||||
" <href>/</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <resourcetype><collection/><CARD:addressbook/></resourcetype>" +
|
||||
" <displayname>My Contacts</displayname>" +
|
||||
" <CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(Collection.TYPE_ADDRESSBOOK, info.type)
|
||||
assertTrue(info.privWriteContent)
|
||||
assertTrue(info.privUnbind)
|
||||
assertNull(info.supportsVEVENT)
|
||||
assertNull(info.supportsVTODO)
|
||||
assertNull(info.supportsVJOURNAL)
|
||||
assertEquals("My Contacts", info.displayName)
|
||||
assertEquals("My Contacts Description", info.description)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testFromDavResponseCalendar() {
|
||||
// read-only calendar, no display name
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
|
||||
"<response>" +
|
||||
" <href>/</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
|
||||
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
|
||||
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
|
||||
" <CAL:calendar-timezone>tzdata</CAL:calendar-timezone>" +
|
||||
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(Collection.TYPE_CALENDAR, info.type)
|
||||
assertFalse(info.privWriteContent)
|
||||
assertFalse(info.privUnbind)
|
||||
assertNull(info.displayName)
|
||||
assertEquals("My Calendar", info.description)
|
||||
assertEquals(0xFFFF0000.toInt(), info.color)
|
||||
assertEquals("tzdata", info.timezone)
|
||||
assertTrue(info.supportsVEVENT!!)
|
||||
assertTrue(info.supportsVTODO!!)
|
||||
assertTrue(info.supportsVJOURNAL!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testFromDavResponseWebcal() {
|
||||
// Webcal subscription
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CS='http://calendarserver.org/ns/'>" +
|
||||
"<response>" +
|
||||
" <href>/webcal1</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <displayname>Sample Subscription</displayname>" +
|
||||
" <resourcetype><collection/><CS:subscribed/></resourcetype>" +
|
||||
" <CS:source><href>webcals://example.com/1.ics</href></CS:source>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(Collection.TYPE_WEBCAL, info.type)
|
||||
assertEquals("Sample Subscription", info.displayName)
|
||||
assertEquals("https://example.com/1.ics".toHttpUrl(), info.source)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class HomesetDaoTest {
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInsertOrUpdate() {
|
||||
// should insert new row or update (upsert) existing row - without changing its key!
|
||||
val serviceId = createTestService()
|
||||
val homeSetDao = db.homeSetDao()
|
||||
|
||||
val entry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl())
|
||||
val insertId1 = homeSetDao.insertOrUpdateByUrl(entry1)
|
||||
assertEquals(1L, insertId1)
|
||||
assertEquals(entry1.apply { id = 1L }, homeSetDao.getById(1L))
|
||||
|
||||
val updatedEntry1 = HomeSet(id=0, serviceId=serviceId, personal=true, url="https://example.com/1".toHttpUrl(), displayName="Updated Entry")
|
||||
val updateId1 = homeSetDao.insertOrUpdateByUrl(updatedEntry1)
|
||||
assertEquals(1L, updateId1)
|
||||
assertEquals(updatedEntry1.apply { id = 1L }, homeSetDao.getById(1L))
|
||||
|
||||
val entry2 = HomeSet(id=0, serviceId=serviceId, personal=true, url= "https://example.com/2".toHttpUrl())
|
||||
val insertId2 = homeSetDao.insertOrUpdateByUrl(entry2)
|
||||
assertEquals(2L, insertId2)
|
||||
assertEquals(entry2.apply { id = 2L }, homeSetDao.getById(2L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDelete() {
|
||||
// should delete row with given primary key (id)
|
||||
val serviceId = createTestService()
|
||||
val homesetDao = db.homeSetDao()
|
||||
|
||||
val entry1 = HomeSet(id=1, serviceId=serviceId, personal=true, url= "https://example.com/1".toHttpUrl())
|
||||
|
||||
val insertId1 = homesetDao.insertOrUpdateByUrl(entry1)
|
||||
assertEquals(1L, insertId1)
|
||||
assertEquals(entry1, homesetDao.getById(1L))
|
||||
|
||||
homesetDao.delete(entry1)
|
||||
|
||||
assertEquals(null, homesetDao.getById(1L))
|
||||
}
|
||||
|
||||
fun createTestService() : Long {
|
||||
val serviceDao = db.serviceDao()
|
||||
val service = Service(id=0, accountName="test", type=Service.TYPE_CALDAV, principal = null)
|
||||
return serviceDao.insertOrReplace(service)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
|
||||
import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN
|
||||
import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
|
||||
import android.net.NetworkCapabilities.TRANSPORT_WIFI
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.mockk
|
||||
import junit.framework.TestCase.assertFalse
|
||||
import junit.framework.TestCase.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@HiltAndroidTest
|
||||
class ConnectionUtilsTest {
|
||||
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
private val connectivityManager = mockk<ConnectivityManager>()
|
||||
private val network1 = mockk<Network>()
|
||||
private val network2 = mockk<Network>()
|
||||
private val capabilities = mockk<NetworkCapabilities>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
every { connectivityManager.allNetworks } returns arrayOf(network1, network2)
|
||||
every { connectivityManager.getNetworkInfo(network1) } returns mockk()
|
||||
every { connectivityManager.getNetworkInfo(network2) } returns mockk()
|
||||
every { connectivityManager.getNetworkCapabilities(network1) } returns capabilities
|
||||
every { connectivityManager.getNetworkCapabilities(network2) } returns capabilities
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiAvailable_capabilitiesNull() {
|
||||
every { connectivityManager.getNetworkCapabilities(network1) } returns null
|
||||
every { connectivityManager.getNetworkCapabilities(network2) } returns null
|
||||
assertFalse(ConnectionUtils.wifiAvailable(connectivityManager))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiAvailable() {
|
||||
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns false
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false
|
||||
assertFalse(ConnectionUtils.wifiAvailable(connectivityManager))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiAvailable_wifi() {
|
||||
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false
|
||||
assertFalse(ConnectionUtils.wifiAvailable(connectivityManager))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiAvailable_validated() {
|
||||
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns false
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
assertFalse(ConnectionUtils.wifiAvailable(connectivityManager))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiAvailable_wifiValidated() {
|
||||
every { capabilities.hasTransport(TRANSPORT_WIFI) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
assertTrue(ConnectionUtils.wifiAvailable(connectivityManager))
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_capabilitiesNull() {
|
||||
every { connectivityManager.getNetworkCapabilities(network1) } returns null
|
||||
every { connectivityManager.getNetworkCapabilities(network2) } returns null
|
||||
assertFalse(ConnectionUtils.internetAvailable(connectivityManager, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_Internet() {
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false
|
||||
assertFalse(ConnectionUtils.internetAvailable(connectivityManager, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_Validated() {
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns false
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
assertFalse(ConnectionUtils.internetAvailable(connectivityManager, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_InternetValidated() {
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
assertTrue(ConnectionUtils.internetAvailable(connectivityManager, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_ignoreVpns() {
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_NOT_VPN) } returns false
|
||||
assertFalse(ConnectionUtils.internetAvailable(connectivityManager, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_ignoreVpns_Notvpn() {
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_NOT_VPN) } returns true
|
||||
assertTrue(ConnectionUtils.internetAvailable(connectivityManager, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_twoConnectionsFirstOneWithoutInternet() {
|
||||
// The real case that failed in davx5-ose#395 is that the connection list contains (in this order)
|
||||
// 1. a mobile network without INTERNET, but with VALIDATED
|
||||
// 2. a WiFi network with INTERNET and VALIDATED
|
||||
|
||||
// The "return false" of hasINTERNET will trigger at the first connection, the
|
||||
// "andThen true" will trigger for the second connection
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns false andThen true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
|
||||
// There is an internet connection if any(!) connection has both INTERNET and VALIDATED.
|
||||
assertTrue(ConnectionUtils.internetAvailable(connectivityManager, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_twoConnectionsFirstOneWithoutValidated() {
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns false andThen true
|
||||
assertTrue(ConnectionUtils.internetAvailable(connectivityManager, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInternetAvailable_twoConnectionsFirstOneWithoutNotvpn() {
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_INTERNET) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_VALIDATED) } returns true
|
||||
every { capabilities.hasCapability(NET_CAPABILITY_NOT_VPN) } returns false andThen true
|
||||
assertTrue(ConnectionUtils.internetAvailable(connectivityManager, true))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.R
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@HiltAndroidTest
|
||||
class LocalAddressBookTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
val mainAccountType = context.getString(R.string.account_type)
|
||||
val mainAccount = Account("main", mainAccountType)
|
||||
|
||||
val addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||
val addressBookAccount = Account("sub", addressBookAccountType)
|
||||
|
||||
val accountManager = AccountManager.get(context)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// TODO DOES NOT WORK: the account immediately starts to sync, which creates the sync adapter services.
|
||||
// The services however can't be created because Hilt is "not ready" (although it has been initialized in the line above).
|
||||
// assertTrue(AccountUtils.createAccount(context, mainAccount, AccountSettings.initialUserData(null)))
|
||||
}
|
||||
|
||||
@After
|
||||
fun cleanup() {
|
||||
accountManager.removeAccount(addressBookAccount, null, null)
|
||||
accountManager.removeAccount(mainAccount, null, null)
|
||||
}
|
||||
|
||||
|
||||
// TODO see above
|
||||
/*@Test
|
||||
fun testMainAccount_AddressBookAccount_WithMainAccount() {
|
||||
// create address book account
|
||||
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, Bundle().apply {
|
||||
putString(LocalAddressBook.USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
|
||||
putString(LocalAddressBook.USER_DATA_MAIN_ACCOUNT_TYPE, mainAccount.type)
|
||||
}))
|
||||
|
||||
// check mainAccount()
|
||||
assertEquals(mainAccount, LocalAddressBook.mainAccount(context, addressBookAccount))
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun testMainAccount_AddressBookAccount_WithoutMainAccount() {
|
||||
// create address book account
|
||||
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, Bundle()))
|
||||
|
||||
// check mainAccount(); should fail because there's no main account
|
||||
LocalAddressBook.mainAccount(context, addressBookAccount)
|
||||
}*/
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun testMainAccount_OtherAccount() {
|
||||
LocalAddressBook.mainAccount(context, Account("Other Account", "com.example"))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||
import android.provider.CalendarContract.Events
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.InitCalendarProviderRule
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.util.MiscUtils.closeCompat
|
||||
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
|
||||
import net.fortuna.ical4j.model.property.DtStart
|
||||
import net.fortuna.ical4j.model.property.RRule
|
||||
import net.fortuna.ical4j.model.property.RecurrenceId
|
||||
import net.fortuna.ical4j.model.property.Status
|
||||
import org.junit.*
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.rules.TestRule
|
||||
|
||||
class LocalCalendarTest {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.getInstance()
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun setUpProvider() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun closeProvider() {
|
||||
provider.closeCompat()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
|
||||
private lateinit var calendar: LocalCalendar
|
||||
|
||||
@Before
|
||||
fun prepare() {
|
||||
val uri = AndroidCalendar.create(account, provider, ContentValues())
|
||||
calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
|
||||
}
|
||||
|
||||
@After
|
||||
fun shutdown() {
|
||||
calendar.delete()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDeleteDirtyEventsWithoutInstances_NoInstances_CancelledExceptions() {
|
||||
// create recurring event with only deleted/cancelled instances
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 3 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220120T010203Z")
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Cancelled exception on 1st day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220121T010203Z")
|
||||
dtStart = DtStart("20220121T010203Z")
|
||||
summary = "Cancelled exception on 2nd day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220122T010203Z")
|
||||
dtStart = DtStart("20220122T010203Z")
|
||||
summary = "Cancelled exception on 3rd day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
localEvent.add()
|
||||
val eventId = localEvent.id!!
|
||||
|
||||
// set event as dirty
|
||||
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
|
||||
put(Events.DIRTY, 1)
|
||||
}, null, null)
|
||||
|
||||
// this method should mark the event as deleted
|
||||
calendar.deleteDirtyEventsWithoutInstances()
|
||||
|
||||
// verify that event is now marked as deleted
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
|
||||
arrayOf(Events.DELETED), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
assertEquals(1, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
// Flaky, Needs single or rec init of CalendarProvider (InitCalendarProviderRule)
|
||||
fun testDeleteDirtyEventsWithoutInstances_Recurring_Instances() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 3 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
localEvent.add()
|
||||
val eventId = localEvent.id!!
|
||||
|
||||
// set event as dirty
|
||||
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
|
||||
put(Events.DIRTY, 1)
|
||||
}, null, null)
|
||||
|
||||
// this method should mark the event as deleted
|
||||
calendar.deleteDirtyEventsWithoutInstances()
|
||||
|
||||
// verify that event is not marked as deleted
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
|
||||
arrayOf(Events.DELETED), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
assertEquals(0, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,472 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.os.Build
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||
import android.provider.CalendarContract.Events
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.InitCalendarProviderRule
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.util.MiscUtils.closeCompat
|
||||
import at.techbee.jtx.JtxContract.asSyncAdapter
|
||||
import net.fortuna.ical4j.model.Date
|
||||
import net.fortuna.ical4j.model.DateList
|
||||
import net.fortuna.ical4j.model.parameter.Value
|
||||
import net.fortuna.ical4j.model.property.*
|
||||
import org.junit.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.rules.TestRule
|
||||
import java.util.*
|
||||
|
||||
class LocalEventTest {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val initCalendarProviderRule: TestRule = InitCalendarProviderRule.getInstance()
|
||||
|
||||
private val account = Account("LocalCalendarTest", ACCOUNT_TYPE_LOCAL)
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
private lateinit var calendar: LocalCalendar
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun connect() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
provider = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!!
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun disconnect() {
|
||||
provider.closeCompat()
|
||||
}
|
||||
}
|
||||
|
||||
@Before
|
||||
fun createCalendar() {
|
||||
val uri = AndroidCalendar.create(account, provider, ContentValues())
|
||||
calendar = AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
|
||||
}
|
||||
|
||||
@After
|
||||
fun removeCalendar() {
|
||||
calendar.delete()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testNumDirectInstances_SingleInstance() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 1 instance"
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertEquals(1, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNumDirectInstances_Recurring() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 5 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=5"))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertEquals(5, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNumDirectInstances_Recurring_Endless() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event without end"
|
||||
rRules.add(RRule("FREQ=DAILY"))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertNull(LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
// flaky, needs InitCalendarProviderRule
|
||||
fun testNumDirectInstances_Recurring_LateEnd() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 53 years"
|
||||
rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year 2074 is not supported by Android <11 Calendar Storage
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
|
||||
assertEquals(52, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
|
||||
else
|
||||
assertNull(LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
// flaky, needs InitCalendarProviderRule
|
||||
fun testNumDirectInstances_Recurring_ManyInstances() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 2 years"
|
||||
rRules.add(RRule("FREQ=DAILY;UNTIL=20240120T010203Z"))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
val number = LocalEvent.numDirectInstances(provider, account, localEvent.id!!)
|
||||
|
||||
// Some android versions (i.e. <=Q and S) return 365*2 instances (wrong, 365*2+1 => correct),
|
||||
// but we are satisfied with either result for now
|
||||
assertTrue(number == 365*2 || number == 365*2+1)
|
||||
}
|
||||
|
||||
@Test
|
||||
// flaky, needs InitCalendarProviderRule
|
||||
fun testNumDirectInstances_RecurringWithExdate() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart(Date("20220120T010203Z"))
|
||||
summary = "Event with 5 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=5"))
|
||||
exDates.add(ExDate(DateList("20220121T010203Z", Value.DATE_TIME)))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertEquals(4, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNumDirectInstances_RecurringWithExceptions() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 5 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=5"))
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220122T010203Z")
|
||||
dtStart = DtStart("20220122T130203Z")
|
||||
summary = "Exception on 3rd day"
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220124T010203Z")
|
||||
dtStart = DtStart("20220122T160203Z")
|
||||
summary = "Exception on 5th day"
|
||||
})
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertEquals(5-2, LocalEvent.numDirectInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
// flaky, needs InitCalendarProviderRule
|
||||
fun testNumInstances_SingleInstance() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 1 instance"
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertEquals(1, LocalEvent.numInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNumInstances_Recurring() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 5 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=5"))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertEquals(5, LocalEvent.numInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNumInstances_Recurring_Endless() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with infinite instances"
|
||||
rRules.add(RRule("FREQ=YEARLY"))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertNull(LocalEvent.numInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
// flaky, needs InitCalendarProviderRule
|
||||
fun testNumInstances_Recurring_LateEnd() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event over 22 years"
|
||||
rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year 2074 not supported by Android <11 Calendar Storage
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
|
||||
assertEquals(52, LocalEvent.numInstances(provider, account, localEvent.id!!))
|
||||
else
|
||||
assertNull(LocalEvent.numInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
// flaky, needs InitCalendarProviderRule
|
||||
fun testNumInstances_Recurring_ManyInstances() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event over two years"
|
||||
rRules.add(RRule("FREQ=DAILY;UNTIL=20240120T010203Z"))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
assertEquals(
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q)
|
||||
365*2 // Android <10: does not include UNTIL (incorrect!)
|
||||
else
|
||||
365*2 + 1, // Android ≥10: includes UNTIL (correct)
|
||||
LocalEvent.numInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNumInstances_RecurringWithExceptions() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 6 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=6"))
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220122T010203Z")
|
||||
dtStart = DtStart("20220122T130203Z")
|
||||
summary = "Exception on 3rd day"
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220124T010203Z")
|
||||
dtStart = DtStart("20220122T160203Z")
|
||||
summary = "Exception on 5th day"
|
||||
})
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, 0)
|
||||
val uri = localEvent.add()
|
||||
|
||||
calendar.findById(localEvent.id!!)
|
||||
|
||||
assertEquals(6, LocalEvent.numInstances(provider, account, localEvent.id!!))
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMarkEventAsDeleted() {
|
||||
// Create event
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "A fine event"
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add()
|
||||
|
||||
// Delete event
|
||||
LocalEvent.markAsDeleted(provider, account, localEvent.id!!)
|
||||
|
||||
// Get the status of whether the event is deleted
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
|
||||
arrayOf(Events.DELETED),
|
||||
null,
|
||||
null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertEquals(1, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testPrepareForUpload_NoUid() {
|
||||
// create event
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event without uid"
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add() // save it to calendar storage
|
||||
|
||||
// prepare for upload - this should generate a new random uuid, returned as filename
|
||||
val fileNameWithSuffix = localEvent.prepareForUpload()
|
||||
val fileName = fileNameWithSuffix.removeSuffix(".ics")
|
||||
|
||||
// throws an exception if fileName is not an UUID
|
||||
UUID.fromString(fileName)
|
||||
|
||||
// UID in calendar storage should be the same as file name
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
|
||||
arrayOf(Events.UID_2445), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertEquals(fileName, cursor.getString(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPrepareForUpload_NormalUid() {
|
||||
// create event
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with normal uid"
|
||||
uid = "some-event@hostname.tld" // old UID format, UUID would be new format
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add() // save it to calendar storage
|
||||
|
||||
// prepare for upload - this should use the UID for the file name
|
||||
val fileNameWithSuffix = localEvent.prepareForUpload()
|
||||
val fileName = fileNameWithSuffix.removeSuffix(".ics")
|
||||
|
||||
assertEquals(event.uid, fileName)
|
||||
|
||||
// UID in calendar storage should still be set, too
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
|
||||
arrayOf(Events.UID_2445), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertEquals(fileName, cursor.getString(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPrepareForUpload_UidHasDangerousChars() {
|
||||
// create event
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with funny uid"
|
||||
uid = "https://www.example.com/events/asdfewfe-cxyb-ewrws-sadfrwerxyvser-asdfxye-"
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, null, null, null, 0)
|
||||
localEvent.add() // save it to calendar storage
|
||||
|
||||
// prepare for upload - this should generate a new random uuid, returned as filename
|
||||
val fileNameWithSuffix = localEvent.prepareForUpload()
|
||||
val fileName = fileNameWithSuffix.removeSuffix(".ics")
|
||||
|
||||
// throws an exception if fileName is not an UUID
|
||||
UUID.fromString(fileName)
|
||||
|
||||
// UID in calendar storage shouldn't have been changed
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(account),
|
||||
arrayOf(Events.UID_2445), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertEquals(event.uid, cursor.getString(0))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDeleteDirtyEventsWithoutInstances_NoInstances_Exdate() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeleteDirtyEventsWithoutInstances_NoInstances_CancelledExceptions() {
|
||||
// create recurring event with only deleted/cancelled instances
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 3 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220120T010203Z")
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Cancelled exception on 1st day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220121T010203Z")
|
||||
dtStart = DtStart("20220121T010203Z")
|
||||
summary = "Cancelled exception on 2nd day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
exceptions.add(Event().apply {
|
||||
recurrenceId = RecurrenceId("20220122T010203Z")
|
||||
dtStart = DtStart("20220122T010203Z")
|
||||
summary = "Cancelled exception on 3rd day"
|
||||
status = Status.VEVENT_CANCELLED
|
||||
})
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
localEvent.add()
|
||||
val eventId = localEvent.id!!
|
||||
|
||||
// set event as dirty
|
||||
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
|
||||
put(Events.DIRTY, 1)
|
||||
}, null, null)
|
||||
|
||||
// this method should mark the event as deleted
|
||||
calendar.deleteDirtyEventsWithoutInstances()
|
||||
|
||||
// verify that event is now marked as deleted
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
|
||||
arrayOf(Events.DELETED), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
assertEquals(1, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeleteDirtyEventsWithoutInstances_Recurring_Instances() {
|
||||
val event = Event().apply {
|
||||
dtStart = DtStart("20220120T010203Z")
|
||||
summary = "Event with 3 instances"
|
||||
rRules.add(RRule("FREQ=DAILY;COUNT=3"))
|
||||
}
|
||||
val localEvent = LocalEvent(calendar, event, "filename.ics", null, null, LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
localEvent.add()
|
||||
val eventId = localEvent.id!!
|
||||
|
||||
// set event as dirty
|
||||
provider.update(ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId), ContentValues(1).apply {
|
||||
put(Events.DIRTY, 1)
|
||||
}, null, null)
|
||||
|
||||
// this method should mark the event as deleted
|
||||
calendar.deleteDirtyEventsWithoutInstances()
|
||||
|
||||
// verify that event is not marked as deleted
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI.asSyncAdapter(account), eventId),
|
||||
arrayOf(Events.DELETED), null, null, null
|
||||
)!!.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
assertEquals(0, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.vcard4android.BatchOperation
|
||||
import at.bitfire.vcard4android.CachedGroupMembership
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.*
|
||||
import org.junit.Assert.*
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class LocalGroupTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var settingsManager: SettingsManager
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
|
||||
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
|
||||
private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun connect() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
assertNotNull(provider)
|
||||
|
||||
addressBookGroupsAsCategories = LocalTestAddressBook(context, provider, GroupMethod.CATEGORIES)
|
||||
addressBookGroupsAsVCards = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun disconnect() {
|
||||
@Suppress("DEPRECATION")
|
||||
provider.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun newGroup(addressBook: LocalAddressBook = addressBookGroupsAsCategories): LocalGroup =
|
||||
LocalGroup(addressBook,
|
||||
Contact().apply {
|
||||
displayName = "Test Group"
|
||||
}, null, null, 0
|
||||
).apply {
|
||||
add()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Before
|
||||
fun clearContacts() {
|
||||
addressBookGroupsAsCategories.clear()
|
||||
addressBookGroupsAsVCards.clear()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testApplyPendingMemberships_addPendingMembership() {
|
||||
val ab = addressBookGroupsAsVCards
|
||||
|
||||
val contact1 = LocalContact(ab, Contact().apply {
|
||||
uid = "test1"
|
||||
displayName = "Test"
|
||||
}, "test1.vcf", null, 0)
|
||||
contact1.add()
|
||||
|
||||
val group = newGroup(ab)
|
||||
// set pending membership of contact1
|
||||
ab.provider!!.update(
|
||||
ContentUris.withAppendedId(ab.groupsSyncUri(), group.id!!),
|
||||
ContentValues().apply {
|
||||
put(LocalGroup.COLUMN_PENDING_MEMBERS, LocalGroup.PendingMemberships(setOf("test1")).toString())
|
||||
},
|
||||
null, null
|
||||
)
|
||||
|
||||
// pending membership -> contact1 should be added to group
|
||||
LocalGroup.applyPendingMemberships(ab)
|
||||
|
||||
// check group membership
|
||||
ab.provider!!.query(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(GroupMembership.GROUP_ROW_ID, GroupMembership.RAW_CONTACT_ID),
|
||||
"${GroupMembership.MIMETYPE}=?", arrayOf(GroupMembership.CONTENT_ITEM_TYPE),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
assertTrue(cursor.moveToNext())
|
||||
assertEquals(group.id, cursor.getLong(0))
|
||||
assertEquals(contact1.id, cursor.getLong(1))
|
||||
|
||||
assertFalse(cursor.moveToNext())
|
||||
}
|
||||
// check cached group membership
|
||||
ab.provider!!.query(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
|
||||
"${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
assertTrue(cursor.moveToNext())
|
||||
assertEquals(group.id, cursor.getLong(0))
|
||||
assertEquals(contact1.id, cursor.getLong(1))
|
||||
|
||||
assertFalse(cursor.moveToNext())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testApplyPendingMemberships_removeMembership() {
|
||||
val ab = addressBookGroupsAsVCards
|
||||
|
||||
val contact1 = LocalContact(ab, Contact().apply {
|
||||
uid = "test1"
|
||||
displayName = "Test"
|
||||
}, "test1.vcf", null, 0)
|
||||
contact1.add()
|
||||
|
||||
val group = newGroup(ab)
|
||||
|
||||
// add contact1 to group
|
||||
val batch = BatchOperation(ab.provider!!)
|
||||
contact1.addToGroup(batch, group.id!!)
|
||||
batch.commit()
|
||||
|
||||
// no pending memberships -> membership should be removed
|
||||
LocalGroup.applyPendingMemberships(ab)
|
||||
|
||||
// check group membership
|
||||
ab.provider!!.query(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(GroupMembership.GROUP_ROW_ID, GroupMembership.RAW_CONTACT_ID),
|
||||
"${GroupMembership.MIMETYPE}=?", arrayOf(GroupMembership.CONTENT_ITEM_TYPE),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
assertFalse(cursor.moveToNext())
|
||||
}
|
||||
// check cached group membership
|
||||
ab.provider!!.query(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
|
||||
"${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
assertFalse(cursor.moveToNext())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testClearDirty_addCachedGroupMembership() {
|
||||
val ab = addressBookGroupsAsCategories
|
||||
val group = newGroup(ab)
|
||||
|
||||
val contact1 = LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0)
|
||||
contact1.add()
|
||||
|
||||
// insert group membership, but no cached group membership
|
||||
ab.provider!!.insert(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), ContentValues().apply {
|
||||
put(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
|
||||
put(GroupMembership.RAW_CONTACT_ID, contact1.id)
|
||||
put(GroupMembership.GROUP_ROW_ID, group.id)
|
||||
}
|
||||
)
|
||||
|
||||
group.clearDirty(null, null)
|
||||
|
||||
// check cached group membership
|
||||
ab.provider!!.query(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
|
||||
"${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
assertTrue(cursor.moveToNext())
|
||||
assertEquals(group.id, cursor.getLong(0))
|
||||
assertEquals(contact1.id, cursor.getLong(1))
|
||||
|
||||
assertFalse(cursor.moveToNext())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testClearDirty_removeCachedGroupMembership() {
|
||||
val ab = addressBookGroupsAsCategories
|
||||
val group = newGroup(ab)
|
||||
|
||||
val contact1 = LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0)
|
||||
contact1.add()
|
||||
|
||||
// insert cached group membership, but no group membership
|
||||
ab.provider!!.insert(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), ContentValues().apply {
|
||||
put(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
put(CachedGroupMembership.RAW_CONTACT_ID, contact1.id)
|
||||
put(CachedGroupMembership.GROUP_ID, group.id)
|
||||
}
|
||||
)
|
||||
|
||||
group.clearDirty(null, null)
|
||||
|
||||
// cached group membership should be gone
|
||||
ab.provider!!.query(
|
||||
ab.syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(CachedGroupMembership.GROUP_ID, CachedGroupMembership.RAW_CONTACT_ID),
|
||||
"${CachedGroupMembership.MIMETYPE}=?", arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE),
|
||||
null
|
||||
)!!.use { cursor ->
|
||||
assertFalse(cursor.moveToNext())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMarkMembersDirty() {
|
||||
val ab = addressBookGroupsAsCategories
|
||||
val group = newGroup(ab)
|
||||
|
||||
val contact1 = LocalContact(ab, Contact().apply { displayName = "Test" }, "fn.vcf", null, 0)
|
||||
contact1.add()
|
||||
|
||||
val batch = BatchOperation(ab.provider!!)
|
||||
contact1.addToGroup(batch, group.id!!)
|
||||
batch.commit()
|
||||
|
||||
assertEquals(0, ab.findDirty().size)
|
||||
group.markMembersDirty()
|
||||
assertEquals(contact1.id, ab.findDirty().first().id)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testPrepareForUpload() {
|
||||
val group = newGroup()
|
||||
assertNull(group.getContact().uid)
|
||||
|
||||
val fileName = group.prepareForUpload()
|
||||
val newUid = group.getContact().uid
|
||||
assertNotNull(newUid)
|
||||
assertEquals("$newUid.vcf", fileName)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
|
||||
class LocalTestAddressBook(
|
||||
context: Context,
|
||||
provider: ContentProviderClient,
|
||||
override val groupMethod: GroupMethod
|
||||
): LocalAddressBook(context, ACCOUNT, provider) {
|
||||
|
||||
companion object {
|
||||
val ACCOUNT = Account("LocalTestAddressBook", "at.bitfire.davdroid.test")
|
||||
}
|
||||
|
||||
override var mainAccount: Account?
|
||||
get() = throw NotImplementedError()
|
||||
set(_) = throw NotImplementedError()
|
||||
|
||||
override var readOnly: Boolean
|
||||
get() = false
|
||||
set(_) = throw NotImplementedError()
|
||||
|
||||
|
||||
fun clear() {
|
||||
for (contact in queryContacts(null, null))
|
||||
contact.delete()
|
||||
for (group in queryGroups(null, null))
|
||||
group.delete()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource.contactrow
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import android.provider.ContactsContract
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.davdroid.resource.LocalContact
|
||||
import at.bitfire.davdroid.resource.LocalTestAddressBook
|
||||
import at.bitfire.vcard4android.CachedGroupMembership
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import org.junit.*
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
|
||||
class CachedGroupMembershipHandlerTest {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
private lateinit var addressBook: LocalTestAddressBook
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun connect() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().context
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
Assert.assertNotNull(provider)
|
||||
|
||||
addressBook = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun disconnect() {
|
||||
@Suppress("DEPRECATION")
|
||||
provider.release()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMembership() {
|
||||
val contact = Contact()
|
||||
val localContact = LocalContact(addressBook, contact, null, null, 0)
|
||||
CachedGroupMembershipHandler(localContact).handle(ContentValues().apply {
|
||||
put(CachedGroupMembership.GROUP_ID, 123456)
|
||||
put(CachedGroupMembership.RAW_CONTACT_ID, 789)
|
||||
}, contact)
|
||||
assertArrayEquals(arrayOf(123456L), localContact.cachedGroupMemberships.toArray())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource.contactrow
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentProviderClient
|
||||
import android.net.Uri
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.davdroid.resource.LocalTestAddressBook
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import org.junit.*
|
||||
import org.junit.Assert.assertEquals
|
||||
|
||||
class GroupMembershipBuilderTest {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
|
||||
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
|
||||
private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun connect() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
Assert.assertNotNull(provider)
|
||||
|
||||
addressBookGroupsAsCategories = LocalTestAddressBook(context, provider, GroupMethod.CATEGORIES)
|
||||
addressBookGroupsAsVCards = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun disconnect() {
|
||||
@Suppress("DEPRECATION")
|
||||
provider.release()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testCategories_GroupsAsCategories() {
|
||||
val contact = Contact().apply {
|
||||
categories += "TEST GROUP"
|
||||
}
|
||||
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsCategories, false).build().also { result ->
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(GroupMembership.CONTENT_ITEM_TYPE, result[0].values[GroupMembership.MIMETYPE])
|
||||
assertEquals(addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP"), result[0].values[GroupMembership.GROUP_ROW_ID])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCategories_GroupsAsVCards() {
|
||||
val contact = Contact().apply {
|
||||
categories += "TEST GROUP"
|
||||
}
|
||||
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsVCards, false).build().also { result ->
|
||||
// group membership is constructed during post-processing
|
||||
assertEquals(0, result.size)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource.contactrow
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import android.provider.ContactsContract
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.davdroid.resource.LocalContact
|
||||
import at.bitfire.davdroid.resource.LocalTestAddressBook
|
||||
import at.bitfire.vcard4android.CachedGroupMembership
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import org.junit.*
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
|
||||
class GroupMembershipHandlerTest {
|
||||
|
||||
@JvmField
|
||||
@Rule
|
||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
|
||||
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
|
||||
private var addressBookGroupsAsCategoriesGroup: Long = -1
|
||||
|
||||
private lateinit var addressBookGroupsAsVCards: LocalTestAddressBook
|
||||
|
||||
@Before
|
||||
fun connect() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
Assert.assertNotNull(provider)
|
||||
|
||||
addressBookGroupsAsCategories = LocalTestAddressBook(context, provider, GroupMethod.CATEGORIES)
|
||||
addressBookGroupsAsCategoriesGroup = addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP")
|
||||
|
||||
addressBookGroupsAsVCards = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
|
||||
}
|
||||
|
||||
@After
|
||||
fun disconnect() {
|
||||
@Suppress("DEPRECATION")
|
||||
provider.release()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMembership_GroupsAsCategories() {
|
||||
val contact = Contact()
|
||||
val localContact = LocalContact(addressBookGroupsAsCategories, contact, null, null, 0)
|
||||
GroupMembershipHandler(localContact).handle(ContentValues().apply {
|
||||
put(CachedGroupMembership.GROUP_ID, addressBookGroupsAsCategoriesGroup)
|
||||
put(CachedGroupMembership.RAW_CONTACT_ID, -1)
|
||||
}, contact)
|
||||
assertArrayEquals(arrayOf(addressBookGroupsAsCategoriesGroup), localContact.groupMemberships.toArray())
|
||||
assertArrayEquals(arrayOf("TEST GROUP"), contact.categories.toArray())
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testMembership_GroupsAsVCards() {
|
||||
val contact = Contact()
|
||||
val localContact = LocalContact(addressBookGroupsAsVCards, contact, null, null, 0)
|
||||
GroupMembershipHandler(localContact).handle(ContentValues().apply {
|
||||
put(CachedGroupMembership.GROUP_ID, 12345) // because the group name is not queried and put into CATEGORIES, the group doesn't have to really exist
|
||||
put(CachedGroupMembership.RAW_CONTACT_ID, -1)
|
||||
}, contact)
|
||||
assertArrayEquals(arrayOf(12345L), localContact.groupMemberships.toArray())
|
||||
assertTrue(contact.categories.isEmpty())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,755 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.content.Context
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import android.util.Log
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.HomeSet
|
||||
import at.bitfire.davdroid.db.Principal
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.mockk
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class RefreshCollectionsWorkerTest {
|
||||
|
||||
@get:Rule
|
||||
var hiltRule = HiltAndroidRule(this)
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// The test application is an instance of HiltTestApplication, which doesn't initialize notification channels.
|
||||
// However, we need notification channels for the ongoing work notifications.
|
||||
NotificationUtils.createChannels(context)
|
||||
|
||||
// Initialize WorkManager for instrumentation tests.
|
||||
val config = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(Log.DEBUG)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
|
||||
}
|
||||
|
||||
|
||||
// Test dependencies
|
||||
|
||||
companion object {
|
||||
private const val PATH_CALDAV = "/caldav"
|
||||
private const val PATH_CARDDAV = "/carddav"
|
||||
|
||||
private const val SUBPATH_PRINCIPAL = "/principal"
|
||||
private const val SUBPATH_PRINCIPAL_INACCESSIBLE = "/inaccessible-principal"
|
||||
private const val SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS = "/principal2"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET = "/addressbooks-homeset"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET_EMPTY = "/addressbooks-homeset-empty"
|
||||
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/my-contacts"
|
||||
private const val SUBPATH_ADDRESSBOOK_INACCESSIBLE = "/addressbooks/inaccessible-contacts"
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var settings: SettingsManager
|
||||
|
||||
var mockServer = MockWebServer()
|
||||
|
||||
lateinit var client: HttpClient
|
||||
|
||||
@Before
|
||||
fun mockServerSetup() {
|
||||
// Start mock web server
|
||||
mockServer.dispatcher = TestDispatcher()
|
||||
mockServer.start()
|
||||
|
||||
client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build()
|
||||
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
}
|
||||
|
||||
@After
|
||||
fun cleanUp() {
|
||||
mockServer.shutdown()
|
||||
db.close()
|
||||
}
|
||||
|
||||
|
||||
// Actual tests
|
||||
|
||||
/* Often fails for unknown reasons:
|
||||
@Test
|
||||
fun testRefreshCollections_enqueuesWorker() {
|
||||
val service = createTestService(Service.TYPE_CALDAV)!!
|
||||
|
||||
val (workerName, enqueueOp) = RefreshCollectionsWorker.enqueue(context, service.id)
|
||||
enqueueOp.result.get()
|
||||
assertTrue(workScheduledOrRunningOrSuccessful(context, workerName))
|
||||
}*/
|
||||
|
||||
@Test
|
||||
fun testDiscoverHomesets() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
val baseUrl = mockServer.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)
|
||||
|
||||
// Query home sets
|
||||
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
.discoverHomesets(baseUrl)
|
||||
|
||||
// Check home sets have been saved to database
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET/"), db.homeSetDao().getByService(service.id).first().url)
|
||||
assertEquals(1, db.homeSetDao().getByService(service.id).size)
|
||||
}
|
||||
|
||||
|
||||
// refreshHomesetsAndTheirCollections
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_addsNewCollection() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// save homeset in DB
|
||||
val homesetId = db.homeSetDao().insert(
|
||||
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
)
|
||||
|
||||
// Refresh
|
||||
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
.refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection defined in homeset is now in the database
|
||||
assertEquals(
|
||||
Collection(
|
||||
1,
|
||||
service.id,
|
||||
homesetId,
|
||||
1, // will have gotten an owner too
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description"
|
||||
),
|
||||
db.collectionDao().getByService(service.id).first()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_updatesExistingCollection() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// save "old" collection in DB
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description"
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh
|
||||
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
.refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection got updated
|
||||
assertEquals(
|
||||
Collection(
|
||||
collectionId,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description"
|
||||
),
|
||||
db.collectionDao().get(collectionId)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_preservesCollectionFlags() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// save "old" collection in DB - with set flags
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description",
|
||||
forceReadOnly = true,
|
||||
sync = true
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh
|
||||
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
.refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection got updated
|
||||
assertEquals(
|
||||
Collection(
|
||||
collectionId,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description",
|
||||
forceReadOnly = true,
|
||||
sync = true
|
||||
),
|
||||
db.collectionDao().get(collectionId)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_marksRemovedCollectionsAsHomeless() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// save homeset in DB - which is empty (zero address books) on the serverside
|
||||
val homesetId = db.homeSetDao().insert(
|
||||
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET_EMPTY"))
|
||||
)
|
||||
|
||||
// place collection in DB - as part of the homeset
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
homesetId,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh - should mark collection as homeless, because serverside homeset is empty
|
||||
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
.refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check the collection, is now marked as homeless
|
||||
assertEquals(null, db.collectionDao().get(collectionId)!!.homeSetId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomesetsAndTheirCollections_addsOwnerUrls() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// save a homeset in DB
|
||||
val homesetId = db.homeSetDao().insert(
|
||||
HomeSet(id=0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
)
|
||||
|
||||
// place collection in DB - as part of the homeset
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
homesetId, // part of above home set
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh - homesets and their collections
|
||||
assertEquals(0, db.principalDao().getByService(service.id).size)
|
||||
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
.refreshHomesetsAndTheirCollections()
|
||||
|
||||
// Check principal saved and the collection was updated with its reference
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(1, principals.size)
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
|
||||
assertEquals(null, principals[0].displayName)
|
||||
assertEquals(
|
||||
principals[0].id,
|
||||
db.collectionDao().get(collectionId)!!.ownerId
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// refreshHomelessCollections
|
||||
|
||||
@Test
|
||||
fun refreshHomelessCollections_updatesExistingCollection() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// place homeless collection in DB
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh
|
||||
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
.refreshHomelessCollections()
|
||||
|
||||
// Check the collection got updated - with display name and description
|
||||
assertEquals(
|
||||
Collection(
|
||||
collectionId,
|
||||
service.id,
|
||||
null,
|
||||
1, // will have gotten an owner too
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
displayName = "My Contacts",
|
||||
description = "My Contacts Description"
|
||||
),
|
||||
db.collectionDao().get(collectionId)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomelessCollections_deletesInaccessibleCollections() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// place homeless collection in DB - it is also inaccessible
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_INACCESSIBLE")
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh - should delete collection
|
||||
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
.refreshHomelessCollections()
|
||||
|
||||
// Check the collection got deleted
|
||||
assertEquals(null, db.collectionDao().get(collectionId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshHomelessCollections_addsOwnerUrls() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// place homeless collection in DB
|
||||
val collectionId = db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
null,
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"),
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh homeless collections
|
||||
assertEquals(0, db.principalDao().getByService(service.id).size)
|
||||
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
.refreshHomelessCollections()
|
||||
|
||||
// Check principal saved and the collection was updated with its reference
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(1, principals.size)
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
|
||||
assertEquals(null, principals[0].displayName)
|
||||
assertEquals(
|
||||
principals[0].id,
|
||||
db.collectionDao().get(collectionId)!!.ownerId
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// refreshPrincipals
|
||||
|
||||
@Test
|
||||
fun refreshPrincipals_inaccessiblePrincipal() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// place principal without display name in db
|
||||
val principalId = db.principalDao().insert(
|
||||
Principal(
|
||||
0,
|
||||
service.id,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), // no trailing slash
|
||||
null // no display name for now
|
||||
)
|
||||
)
|
||||
// add an associated collection - as the principal is rightfully removed otherwise
|
||||
db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
principalId, // create association with principal
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh principals
|
||||
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
.refreshPrincipals()
|
||||
|
||||
// Check principal was not updated
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(1, principals.size)
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_INACCESSIBLE"), principals[0].url)
|
||||
assertEquals(null, principals[0].displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshPrincipals_updatesPrincipal() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// place principal without display name in db
|
||||
val principalId = db.principalDao().insert(
|
||||
Principal(
|
||||
0,
|
||||
service.id,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), // no trailing slash
|
||||
null // no display name for now
|
||||
)
|
||||
)
|
||||
// add an associated collection - as the principal is rightfully removed otherwise
|
||||
db.collectionDao().insertOrUpdateByUrl(
|
||||
Collection(
|
||||
0,
|
||||
service.id,
|
||||
null,
|
||||
principalId, // create association with principal
|
||||
Collection.TYPE_ADDRESSBOOK,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), // with trailing slash
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh principals
|
||||
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
.refreshPrincipals()
|
||||
|
||||
// Check principal now got a display name
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(1, principals.size)
|
||||
assertEquals(mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL"), principals[0].url)
|
||||
assertEquals("Mr. Wobbles", principals[0].displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshPrincipals_deletesPrincipalsWithoutCollections() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
// place principal without collections in DB
|
||||
db.principalDao().insert(
|
||||
Principal(
|
||||
0,
|
||||
service.id,
|
||||
mockServer.url("$PATH_CARDDAV$SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS/")
|
||||
)
|
||||
)
|
||||
|
||||
// Refresh principals - detecting it does not own collections
|
||||
RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
.refreshPrincipals()
|
||||
|
||||
// Check principal was deleted
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
assertEquals(0, principals.size)
|
||||
}
|
||||
|
||||
// Others
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_none() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
val settings = mockk<SettingsManager>()
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_NONE
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
)
|
||||
|
||||
val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_all() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
val settings = mockk<SettingsManager>()
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
)
|
||||
|
||||
val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_all_blacklisted() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
val url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
|
||||
val settings = mockk<SettingsManager>()
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_ALL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns url.toString()
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = url
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
)
|
||||
|
||||
val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_notPersonal() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
val settings = mockk<SettingsManager>()
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(0, service.id, false, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
)
|
||||
|
||||
val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_isPersonal() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
|
||||
val settings = mockk<SettingsManager>()
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns ""
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
)
|
||||
|
||||
val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
assertTrue(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPreselect_personal_isPersonalButBlacklisted() {
|
||||
val service = createTestService(Service.TYPE_CARDDAV)!!
|
||||
val collectionUrl = mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/")
|
||||
|
||||
val settings = mockk<SettingsManager>()
|
||||
every { settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS) } returns Settings.PRESELECT_COLLECTIONS_PERSONAL
|
||||
every { settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED) } returns collectionUrl.toString()
|
||||
|
||||
val collection = Collection(
|
||||
0,
|
||||
service.id,
|
||||
0,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = collectionUrl
|
||||
)
|
||||
val homesets = listOf(
|
||||
HomeSet(0, service.id, true, mockServer.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET"))
|
||||
)
|
||||
|
||||
val refresher = RefreshCollectionsWorker.Refresher(db, service, settings, client.okHttpClient)
|
||||
assertFalse(refresher.shouldPreselect(collection, homesets))
|
||||
}
|
||||
|
||||
// Test helpers and dependencies
|
||||
|
||||
private fun createTestService(serviceType: String) : Service? {
|
||||
val service = Service(id=0, accountName="test", type=serviceType, principal = null)
|
||||
val serviceId = db.serviceDao().insertOrReplace(service)
|
||||
return db.serviceDao().get(serviceId)
|
||||
}
|
||||
|
||||
class TestDispatcher: Dispatcher() {
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = StringUtils.removeEnd(request.path!!, "/")
|
||||
|
||||
if (request.method.equals("PROPFIND", true)) {
|
||||
val properties = when (path) {
|
||||
PATH_CALDAV,
|
||||
PATH_CARDDAV ->
|
||||
"<current-user-principal>" +
|
||||
" <href>$path${SUBPATH_PRINCIPAL}</href>" +
|
||||
"</current-user-principal>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
|
||||
"<resourcetype><principal/></resourcetype>" +
|
||||
"<displayname>Mr. Wobbles</displayname>" +
|
||||
"<CARD:addressbook-home-set>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET}</href>" +
|
||||
"</CARD:addressbook-home-set>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL_WITHOUT_COLLECTIONS ->
|
||||
"<CARD:addressbook-home-set>" +
|
||||
" <href>${PATH_CARDDAV}${SUBPATH_ADDRESSBOOK_HOMESET_EMPTY}</href>" +
|
||||
"</CARD:addressbook-home-set>" +
|
||||
"<displayname>Mr. Wobbles Jr.</displayname>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK,
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET ->
|
||||
"<resourcetype>" +
|
||||
" <collection/>" +
|
||||
" <CARD:addressbook/>" +
|
||||
"</resourcetype>" +
|
||||
"<displayname>My Contacts</displayname>" +
|
||||
"<CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
|
||||
"<owner>" +
|
||||
" <href>${PATH_CARDDAV + SUBPATH_PRINCIPAL}</href>" +
|
||||
"</owner>"
|
||||
|
||||
PATH_CALDAV + SUBPATH_PRINCIPAL ->
|
||||
"<CAL:calendar-user-address-set>" +
|
||||
" <href>urn:unknown-entry</href>" +
|
||||
" <href>mailto:email1@example.com</href>" +
|
||||
" <href>mailto:email2@example.com</href>" +
|
||||
"</CAL:calendar-user-address-set>"
|
||||
|
||||
SUBPATH_ADDRESSBOOK_HOMESET_EMPTY -> ""
|
||||
|
||||
else -> ""
|
||||
}
|
||||
|
||||
var responseBody = ""
|
||||
var responseCode = 207
|
||||
when (path) {
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET ->
|
||||
responseBody =
|
||||
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
" <href>${PATH_CARDDAV + SUBPATH_ADDRESSBOOK}</href>" +
|
||||
" <propstat><prop>" +
|
||||
properties +
|
||||
" </prop></propstat>" +
|
||||
" <status>HTTP/1.1 200 OK</status>" +
|
||||
"</response>" +
|
||||
"</multistatus>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL_INACCESSIBLE,
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK_INACCESSIBLE ->
|
||||
responseCode = 404
|
||||
|
||||
else ->
|
||||
responseBody =
|
||||
"<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
" <href>$path</href>" +
|
||||
" <propstat><prop>"+
|
||||
properties +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"
|
||||
}
|
||||
|
||||
Logger.log.info("Queried: $path")
|
||||
Logger.log.info("Response: $responseBody")
|
||||
return MockResponse()
|
||||
.setResponseCode(responseCode)
|
||||
.setBody(responseBody)
|
||||
}
|
||||
|
||||
return MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentResolver
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import at.bitfire.davdroid.syncadapter.AccountUtils
|
||||
import at.bitfire.davdroid.syncadapter.PeriodicSyncWorker
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
/*@HiltAndroidTest
|
||||
class AccountSettingsTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var settingsManager: SettingsManager
|
||||
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
val account = Account(javaClass.canonicalName, context.getString(R.string.account_type))
|
||||
val fakeCredentials = Credentials("test", "test")
|
||||
|
||||
val authorities = listOf(
|
||||
context.getString(R.string.address_books_authority),
|
||||
CalendarContract.AUTHORITY,
|
||||
TaskProvider.ProviderName.JtxBoard.authority,
|
||||
TaskProvider.ProviderName.OpenTasks.authority,
|
||||
TaskProvider.ProviderName.TasksOrg.authority
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
assertTrue(AccountUtils.createAccount(
|
||||
context,
|
||||
account,
|
||||
AccountSettings.initialUserData(fakeCredentials)
|
||||
))
|
||||
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
|
||||
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0)
|
||||
|
||||
// The test application is an instance of HiltTestApplication, which doesn't initialize notification channels.
|
||||
// However, we need notification channels for the ongoing work notifications.
|
||||
NotificationUtils.createChannels(context)
|
||||
|
||||
// Initialize WorkManager for instrumentation tests.
|
||||
val config = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(Log.DEBUG)
|
||||
.build()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
|
||||
}
|
||||
|
||||
@After
|
||||
@RequiresApi(22)
|
||||
fun removeAccount() {
|
||||
AccountManager.get(context).removeAccountExplicitly(account)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testSyncIntervals() {
|
||||
val settings = AccountSettings(context, account)
|
||||
val presetIntervals =
|
||||
context.resources.getStringArray(R.array.settings_sync_interval_seconds)
|
||||
.map { it.toLong() }
|
||||
.filter { it != AccountSettings.SYNC_INTERVAL_MANUALLY }
|
||||
for (interval in presetIntervals) {
|
||||
assertTrue(settings.setSyncInterval(CalendarContract.AUTHORITY, interval))
|
||||
assertEquals(interval, settings.getSyncInterval(CalendarContract.AUTHORITY))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSyncIntervals_Syncable() {
|
||||
val settings = AccountSettings(context, account)
|
||||
val interval = 15*60L // 15 min
|
||||
val result = settings.setSyncInterval(CalendarContract.AUTHORITY, interval)
|
||||
assertTrue(result)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun testSyncIntervals_TooShort() {
|
||||
val settings = AccountSettings(context, account)
|
||||
val interval = 60L // 1 min is not supported by Android
|
||||
settings.setSyncInterval(CalendarContract.AUTHORITY, interval)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSyncIntervals_activatesPeriodicSyncWorker() {
|
||||
val settings = AccountSettings(context, account)
|
||||
val interval = 15*60L
|
||||
for (authority in authorities) {
|
||||
ContentResolver.setIsSyncable(account, authority, 1)
|
||||
assertTrue(settings.setSyncInterval(authority, interval))
|
||||
assertTrue(TestUtils.workScheduledOrRunningOrSuccessful(context, PeriodicSyncWorker.workerName(account, authority)))
|
||||
assertEquals(interval, settings.getSyncInterval(authority))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSyncIntervals_disablesPeriodicSyncWorker() {
|
||||
val settings = AccountSettings(context, account)
|
||||
val interval = AccountSettings.SYNC_INTERVAL_MANUALLY // -1
|
||||
for (authority in authorities) {
|
||||
ContentResolver.setIsSyncable(account, authority, 1)
|
||||
assertTrue(settings.setSyncInterval(authority, interval))
|
||||
assertFalse(TestUtils.workScheduledOrRunningOrSuccessful(context, PeriodicSyncWorker.workerName(account, authority)))
|
||||
assertEquals(AccountSettings.SYNC_INTERVAL_MANUALLY, settings.getSyncInterval(authority))
|
||||
}
|
||||
}
|
||||
|
||||
}*/
|
||||
@@ -1,79 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui.webdav
|
||||
|
||||
import android.security.NetworkSecurityPolicy
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.WebDavMount
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.spyk
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class AddWebdavMountActivityTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
model = spyk(AddWebdavMountActivity.Model(ApplicationProvider.getApplicationContext(), db))
|
||||
|
||||
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
|
||||
}
|
||||
|
||||
|
||||
lateinit var model: AddWebdavMountActivity.Model
|
||||
val web = MockWebServer()
|
||||
|
||||
@Test
|
||||
fun testHasWebDav_NoDavHeader() {
|
||||
web.enqueue(MockResponse().setResponseCode(200))
|
||||
assertFalse(model.hasWebDav(WebDavMount(name = "Test", url = web.url("/")), null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHasWebDav_DavClass_1() {
|
||||
web.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.addHeader("DAV", "1"))
|
||||
assertTrue(model.hasWebDav(WebDavMount(name = "Test", url = web.url("/")), null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHasWebDav_DavClass_1and2() {
|
||||
web.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.addHeader("DAV", "1,2"))
|
||||
assertTrue(model.hasWebDav(WebDavMount(name = "Test", url = web.url("/")), null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHasWebDav_DavClass_2() {
|
||||
web.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.addHeader("DAV", "2"))
|
||||
assertTrue(model.hasWebDav(WebDavMount(name = "Test", url = web.url("/")), null))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.webdav
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class CredentialsStoreTest {
|
||||
|
||||
private val store = CredentialsStore(InstrumentationRegistry.getInstrumentation().targetContext)
|
||||
|
||||
@Test
|
||||
fun testSetGetDelete() {
|
||||
store.setCredentials(0, Credentials(username = "myname", password = "12345"))
|
||||
assertEquals(Credentials(username = "myname", password = "12345"), store.getCredentials(0))
|
||||
|
||||
store.setCredentials(0, null)
|
||||
assertNull(store.getCredentials(0))
|
||||
}
|
||||
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) Ricki Hirner (bitfire web engineering).
|
||||
~ All rights reserved. This program and the accompanying materials
|
||||
~ are made available under the terms of the GNU Public License v3.0
|
||||
~ which accompanies this distribution, and is available at
|
||||
~ http://www.gnu.org/licenses/gpl.html
|
||||
-->
|
||||
|
||||
<resources>
|
||||
|
||||
<string name="app_name">Davx5Test</string>
|
||||
|
||||
</resources>
|
||||
@@ -1,3 +0,0 @@
|
||||
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accountType="at.bitfire.davdroid.SyncManagerTest"
|
||||
android:label="Test Account" />
|
||||
@@ -1,150 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentResolver
|
||||
import android.net.wifi.WifiInfo
|
||||
import android.net.wifi.WifiManager
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.util.Log
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import at.bitfire.davdroid.network.ConnectionUtils
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.davdroid.util.PermissionUtils
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.spyk
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@HiltAndroidTest
|
||||
class BaseSyncWorkerTest {
|
||||
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
private val accountManager = AccountManager.get(context)
|
||||
private val account = Account("Test Account", context.getString(R.string.account_type))
|
||||
private val fakeCredentials = Credentials("test", "test")
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
@Before
|
||||
fun inject() = hiltRule.inject()
|
||||
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
assertTrue(AccountUtils.createAccount(context, account, AccountSettings.initialUserData(fakeCredentials)))
|
||||
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
|
||||
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0)
|
||||
|
||||
// The test application is an instance of HiltTestApplication, which doesn't initialize notification channels.
|
||||
// However, we need notification channels for the ongoing work notifications.
|
||||
NotificationUtils.createChannels(context)
|
||||
|
||||
// Initialize WorkManager for instrumentation tests.
|
||||
val config = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(Log.DEBUG)
|
||||
.build()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
|
||||
}
|
||||
|
||||
@After
|
||||
fun removeAccount() {
|
||||
accountManager.removeAccountExplicitly(account)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testWifiConditionsMet_withoutWifi() {
|
||||
val accountSettings = mockk<AccountSettings>()
|
||||
every { accountSettings.getSyncWifiOnly() } returns false
|
||||
assertTrue(BaseSyncWorker.wifiConditionsMet(context, accountSettings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiConditionsMet_anyWifi_wifiEnabled() {
|
||||
val accountSettings = AccountSettings(context, account)
|
||||
accountSettings.setSyncWiFiOnly(true)
|
||||
|
||||
mockkObject(ConnectionUtils)
|
||||
every { ConnectionUtils.wifiAvailable(any()) } returns true
|
||||
mockkObject(BaseSyncWorker.Companion)
|
||||
every { BaseSyncWorker.correctWifiSsid(any(), any()) } returns true
|
||||
|
||||
assertTrue(BaseSyncWorker.wifiConditionsMet(context, accountSettings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWifiConditionsMet_anyWifi_wifiDisabled() {
|
||||
val accountSettings = AccountSettings(context, account)
|
||||
accountSettings.setSyncWiFiOnly(true)
|
||||
|
||||
mockkObject(ConnectionUtils)
|
||||
every { ConnectionUtils.wifiAvailable(any()) } returns false
|
||||
mockkObject(BaseSyncWorker.Companion)
|
||||
every { BaseSyncWorker.correctWifiSsid(any(), any()) } returns true
|
||||
|
||||
assertFalse(BaseSyncWorker.wifiConditionsMet(context, accountSettings))
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testCorrectWifiSsid_CorrectWiFiSsid() {
|
||||
val accountSettings = AccountSettings(context, account)
|
||||
mockkObject(accountSettings)
|
||||
every { accountSettings.getSyncWifiOnlySSIDs() } returns listOf("SampleWiFi1","ConnectedWiFi")
|
||||
|
||||
mockkObject(PermissionUtils)
|
||||
every { PermissionUtils.canAccessWifiSsid(any()) } returns true
|
||||
|
||||
val wifiManager = context.getSystemService<WifiManager>()!!
|
||||
mockkObject(wifiManager)
|
||||
every { wifiManager.connectionInfo } returns spyk<WifiInfo>().apply {
|
||||
every { ssid } returns "ConnectedWiFi"
|
||||
}
|
||||
|
||||
assertTrue(BaseSyncWorker.correctWifiSsid(context, accountSettings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCorrectWifiSsid_WrongWiFiSsid() {
|
||||
val accountSettings = AccountSettings(context, account)
|
||||
mockkObject(accountSettings)
|
||||
every { accountSettings.getSyncWifiOnlySSIDs() } returns listOf("SampleWiFi1","SampleWiFi2")
|
||||
|
||||
mockkObject(PermissionUtils)
|
||||
every { PermissionUtils.canAccessWifiSsid(any()) } returns true
|
||||
|
||||
val wifiManager = context.getSystemService<WifiManager>()!!
|
||||
mockkObject(wifiManager)
|
||||
every { wifiManager.connectionInfo } returns spyk<WifiInfo>().apply {
|
||||
every { ssid } returns "ConnectedWiFi"
|
||||
}
|
||||
|
||||
assertFalse(BaseSyncWorker.correctWifiSsid(context, accountSettings))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.syncadapter.SyncManagerTest.Companion.account
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
|
||||
@HiltAndroidTest
|
||||
class OneTimeSyncWorkerTest {
|
||||
|
||||
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
@Test
|
||||
fun testEnqueue_enqueuesWorker() {
|
||||
OneTimeSyncWorker.enqueue(context, account, CalendarContract.AUTHORITY)
|
||||
val workerName = OneTimeSyncWorker.workerName(account, CalendarContract.AUTHORITY)
|
||||
Assert.assertTrue(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.util.Log
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import androidx.work.workDataOf
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.TestUtils.workScheduledOrRunning
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.junit4.MockKRule
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.AfterClass
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@HiltAndroidTest
|
||||
class PeriodicSyncWorkerTest {
|
||||
|
||||
companion object {
|
||||
|
||||
val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
private val accountManager = AccountManager.get(context)
|
||||
private val account = Account("Test Account", context.getString(R.string.account_type))
|
||||
private val fakeCredentials = Credentials("test", "test")
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun setUp() {
|
||||
// The test application is an instance of HiltTestApplication, which doesn't initialize notification channels.
|
||||
// However, we need notification channels for the ongoing work notifications.
|
||||
NotificationUtils.createChannels(context)
|
||||
|
||||
// Initialize WorkManager for instrumentation tests.
|
||||
val config = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(Log.DEBUG)
|
||||
.build()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
|
||||
|
||||
assertTrue(AccountUtils.createAccount(context, account, AccountSettings.initialUserData(fakeCredentials)))
|
||||
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
|
||||
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1)
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun removeAccount() {
|
||||
accountManager.removeAccountExplicitly(account)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
@get:Rule
|
||||
val mockkRule = MockKRule(this)
|
||||
|
||||
@Before
|
||||
fun inject() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun enable_enqueuesPeriodicWorker() {
|
||||
PeriodicSyncWorker.enable(context, account, CalendarContract.AUTHORITY, 60, false)
|
||||
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
|
||||
assertTrue(workScheduledOrRunning(context, workerName))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disable_removesPeriodicWorker() {
|
||||
PeriodicSyncWorker.enable(context, account, CalendarContract.AUTHORITY, 60, false)
|
||||
PeriodicSyncWorker.disable(context, account, CalendarContract.AUTHORITY)
|
||||
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
|
||||
assertFalse(workScheduledOrRunning(context, workerName))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun doWork_cancelsItselfOnInvalidAccount() {
|
||||
val invalidAccount = Account("invalid", context.getString(R.string.account_type))
|
||||
|
||||
// Run PeriodicSyncWorker as TestWorker
|
||||
val inputData = workDataOf(
|
||||
BaseSyncWorker.INPUT_AUTHORITY to CalendarContract.AUTHORITY,
|
||||
BaseSyncWorker.INPUT_ACCOUNT_NAME to invalidAccount.name,
|
||||
BaseSyncWorker.INPUT_ACCOUNT_TYPE to invalidAccount.type
|
||||
)
|
||||
|
||||
// mock WorkManager to observe cancellation call
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
mockkObject(workManager)
|
||||
|
||||
// run test worker, expect failure
|
||||
val testWorker = TestListenableWorkerBuilder<PeriodicSyncWorker>(context, inputData).build()
|
||||
val result = runBlocking {
|
||||
testWorker.doWork()
|
||||
}
|
||||
assertTrue(result is ListenableWorker.Result.Failure)
|
||||
|
||||
// verify that worker called WorkManager.cancelWorkById(<its ID>)
|
||||
verify {
|
||||
workManager.cancelWorkById(testWorker.id)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.R
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@HiltAndroidTest
|
||||
class SyncerTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
/** use our WebDAV provider as a mock provider because it's our own and we don't need any permissions for it */
|
||||
val mockAuthority = context.getString(R.string.webdav_authority)
|
||||
val mockProvider = context.contentResolver!!.acquireContentProviderClient(mockAuthority)!!
|
||||
|
||||
val account = Account(javaClass.canonicalName, context.getString(R.string.account_type))
|
||||
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOnPerformSync_runsSyncAndSetsClassLoader() {
|
||||
val syncer = TestSyncer(context)
|
||||
syncer.onPerformSync(account, arrayOf(), mockAuthority, mockProvider, SyncResult())
|
||||
|
||||
// check whether onPerformSync() actually calls sync()
|
||||
assertEquals(1, syncer.syncCalled.get())
|
||||
|
||||
// check whether contextClassLoader is set
|
||||
assertEquals(context.classLoader, Thread.currentThread().contextClassLoader)
|
||||
}
|
||||
|
||||
|
||||
class TestSyncer(context: Context) : Syncer(context) {
|
||||
|
||||
val syncCalled = AtomicInteger()
|
||||
|
||||
override fun sync(
|
||||
account: Account,
|
||||
extras: Array<String>,
|
||||
authority: String,
|
||||
httpClient: Lazy<HttpClient>,
|
||||
provider: ContentProviderClient,
|
||||
syncResult: SyncResult
|
||||
) {
|
||||
Thread.sleep(1000)
|
||||
syncCalled.incrementAndGet()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,339 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
-->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:installLocation="internalOnly">
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:installLocation="internalOnly">
|
||||
|
||||
<!-- normal permissions -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS"/>
|
||||
<uses-permission android:name="android.permission.READ_SYNC_STATS"/>
|
||||
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||
|
||||
<!-- account management permissions not required for own accounts since API level 22 -->
|
||||
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
|
||||
<!-- other permissions -->
|
||||
<!-- android.permission-group.CONTACTS -->
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
|
||||
<!-- android.permission-group.CALENDAR -->
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR"/>
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR"/>
|
||||
|
||||
<!-- android.permission-group.LOCATION -->
|
||||
<!-- getting the WiFi name (for "sync in Wifi only") requires
|
||||
- coarse location (Android 8.1)
|
||||
- fine location (Android 10) -->
|
||||
<uses-permission-sdk-23 android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
<uses-permission-sdk-23 android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<!-- required since Android 10 to get the WiFi name while in background (= while syncing) -->
|
||||
<uses-permission-sdk-23 android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
|
||||
|
||||
<!-- ical4android declares task access permissions -->
|
||||
|
||||
<!-- Disable GPS capability requirement, which is implicitly derived from ACCESS_FINE_LOCATION
|
||||
permission and makes app unusable on some devices without GPS. We need location permissions only
|
||||
to get the current WiFi SSID, and we don't need GPS for that. -->
|
||||
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="false"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppTheme"
|
||||
android:resizeableActivity="true"
|
||||
tools:ignore="UnusedAttribute"
|
||||
android:supportsRtl="true">
|
||||
|
||||
<!-- required for Hilt/WorkManager integration -->
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
tools:node="remove">
|
||||
</provider>
|
||||
<application android:name=".App">
|
||||
|
||||
<!-- Remove the node added by AppAuth (remove only from net.openid.appauth library, not from our flavor manifest files) -->
|
||||
<activity android:name="net.openid.appauth.RedirectUriReceiverActivity"
|
||||
tools:node="remove" tools:selector="net.openid.appauth"/>
|
||||
|
||||
<activity android:name=".ui.intro.IntroActivity" />
|
||||
<activity
|
||||
android:name=".ui.AccountsActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.AboutActivity"
|
||||
android:label="@string/navigation_drawer_about"
|
||||
android:parentActivityName=".ui.AccountsActivity"/>
|
||||
|
||||
<activity
|
||||
android:name=".ui.AppSettingsActivity"
|
||||
android:label="@string/app_settings"
|
||||
android:parentActivityName=".ui.AccountsActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.APPLICATION_PREFERENCES"/>
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.DebugInfoActivity"
|
||||
android:parentActivityName=".ui.AppSettingsActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/debug_info_title">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BUG_REPORT"/>
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.PermissionsActivity"
|
||||
android:label="@string/app_settings_security_app_permissions"
|
||||
android:parentActivityName=".ui.AppSettingsActivity" />
|
||||
<activity
|
||||
android:name=".ui.TasksActivity"
|
||||
android:label="@string/intro_tasks_title"
|
||||
android:parentActivityName=".ui.AppSettingsActivity" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.setup.LoginActivity"
|
||||
android:parentActivityName=".ui.AccountsActivity"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:scheme="caldav"/>
|
||||
<data android:scheme="caldavs"/>
|
||||
<data android:scheme="carddav"/>
|
||||
<data android:scheme="carddavs"/>
|
||||
<data android:scheme="davx5"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="loginFlow" /> <!-- Ensures this filter matches, even if the sending app is not defining an action -->
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="http" />
|
||||
<data android:scheme="https" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.account.AccountActivity"
|
||||
android:parentActivityName=".ui.AccountsActivity"
|
||||
android:exported="true">
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.account.CreateAddressBookActivity"
|
||||
android:parentActivityName=".ui.account.AccountActivity" />
|
||||
<activity
|
||||
android:name=".ui.account.CreateCalendarActivity"
|
||||
android:parentActivityName=".ui.account.AccountActivity" />
|
||||
<activity
|
||||
android:name=".ui.account.AccountSettingsActivity"
|
||||
android:parentActivityName=".ui.account.AccountActivity" />
|
||||
<activity
|
||||
android:name=".ui.account.WifiPermissionsActivity"
|
||||
android:parentActivityName=".ui.account.AccountSettingsActivity" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.webdav.WebdavMountsActivity"
|
||||
android:exported="true"
|
||||
android:parentActivityName=".ui.AccountsActivity" />
|
||||
<activity
|
||||
android:name=".ui.webdav.AddWebdavMountActivity"
|
||||
android:parentActivityName=".ui.webdav.WebdavMountsActivity"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<!-- account type "DAVx⁵" -->
|
||||
<service
|
||||
android:name=".syncadapter.AccountAuthenticatorService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.AccountAuthenticator"/>
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accounts.AccountAuthenticator"
|
||||
android:resource="@xml/account_authenticator"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".syncadapter.CalendarsSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/sync_calendars"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".syncadapter.JtxSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/sync_notes"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".syncadapter.OpenTasksSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/sync_opentasks"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".syncadapter.TasksOrgSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/sync_tasks_org"/>
|
||||
</service>
|
||||
<provider
|
||||
android:authorities="@string/webdav_authority"
|
||||
android:name=".webdav.DavDocumentsProvider"
|
||||
android:exported="true"
|
||||
android:grantUriPermissions="true"
|
||||
android:permission="android.permission.MANAGE_DOCUMENTS">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||
</intent-filter>
|
||||
</provider>
|
||||
|
||||
<!-- account type "DAVx⁵ Address book" -->
|
||||
<service
|
||||
android:name=".syncadapter.AddressBookAuthenticatorService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService"> <!-- Since Android 11, this must be true so that Google Contacts shows the address book accounts -->
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.AccountAuthenticator"/>
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.accounts.AccountAuthenticator"
|
||||
android:resource="@xml/account_authenticator_address_book"/>
|
||||
</service>
|
||||
<provider
|
||||
android:authorities="@string/address_books_authority"
|
||||
android:exported="false"
|
||||
android:label="@string/address_books_authority_title"
|
||||
android:name=".syncadapter.AddressBookProvider" />
|
||||
<service
|
||||
android:name=".syncadapter.AddressBooksSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/sync_address_books"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".syncadapter.ContactsSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<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>
|
||||
|
||||
<!-- provider to share debug info/logs -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="@string/authority_debug_provider"
|
||||
android:grantUriPermissions="true"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/debug_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- Widgets -->
|
||||
<receiver android:name=".ui.widget.SyncButtonWidgetReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_info_sync_button" />
|
||||
</receiver>
|
||||
tools:node="remove" tools:selector="net.openid.appauth"/>
|
||||
|
||||
</application>
|
||||
|
||||
<!-- package visiblity – which apps do we need to see? -->
|
||||
<queries>
|
||||
<!-- system providers (listing them is technically not required, but some apps like the
|
||||
Huawei calendar take this as indication of whether these providers are accessed) -->
|
||||
<provider android:authorities="com.android.calendar"/>
|
||||
<provider android:authorities="com.android.contacts"/>
|
||||
|
||||
<!-- task providers -->
|
||||
<package android:name="at.techbee.jtx" />
|
||||
<package android:name="org.dmfs.tasks" />
|
||||
<package android:name="org.tasks" />
|
||||
|
||||
<!-- ICSx5 for Webcal feeds -->
|
||||
<package android:name="at.bitfire.icsdroid"/>
|
||||
|
||||
<!-- apps that interact with contact, calendar, task data (for debug info) -->
|
||||
<intent>
|
||||
<action android:name="*" />
|
||||
<data android:scheme="content" android:host="com.android.contacts" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="*" />
|
||||
<data android:scheme="content" android:host="com.android.calendar" />
|
||||
</intent>
|
||||
|
||||
<!-- Open URLs in a browser or other app [https://developer.android.com/training/package-visibility/use-cases#open-urls-browser-or-other-app] -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="https" />
|
||||
</intent>
|
||||
|
||||
<!-- Custom Tabs support (e.g. Nextcloud Login Flow) -->
|
||||
<intent>
|
||||
<action android:name="android.support.customtabs.action.CustomTabsService" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
</manifest>
|
||||
@@ -1 +0,0 @@
|
||||
{"ar_SA":["abdunnasir"],"bg":["dpa_transifex"],"ca":["Kintu","jordibrus","zagur"],"cs":["pavelb","svetlemodry","tomas.odehnal"],"da":["Tntdruid_","knutztar","mjjzf","twikedk"],"de":["Atalanttore","TheName","Wyrrrd","YvanM","amandablue","anestiskaci","corppneq","crit12","hammaschlach","maxkl","nicolas_git","owncube"],"el":["KristinaQejvanaj","anestiskaci","diamond_gr"],"es":["Ark74","Elhea","GranPC","aluaces","jcvielma","plaguna","polkhas","xphnx"],"eu":["Osoitz","Thadah","cockeredradiation"],"fa":["Numb","ahangarha","amiraliakbari","joojoojoo","maryambehzi","mtashackori","taranehsaei"],"fr":["AlainR","Amadeen","Floflr","JorisBodin","Llorc","LoiX07","Novick","Poussinou","Thecross","YvanM","alkino2","boutil","callmemagnus","chrcha","grenatrad","jokx","mathieugfortin","paullbn","vincen","ÉricB."],"fr_FR":["Llorc","Poussinou","chrcha"],"gl":["aluaces","pikamoku"],"hu":["Roshek","infeeeee","jtg"],"it":["Damtux","FranzMari","ed0","malaerba","noccio","nwandy","rickyroo","technezio"],"it_IT":["malaerba"],"ja":["Naofumi","yanorei32"],"nb_NO":["elonus"],"nl":["XtremeNova","davtemp","dehart","erikhubers","frankyboy1963"],"pl":["TORminator","TheName","Valdnet","gsz","mg6","oskarjakiela"],"pt":["amalvarenga","wanderlei.huttel"],"pt_BR":["wanderlei.huttel"],"ru":["aigoshin","anm","ashed","astalavister","nick.savin","vaddd"],"sk_SK":["brango67","tiborepcek"],"sl_SI":["MrLaaky","uroszor"],"sr":["daimonion"],"sv":["Mikaelb","campbelldavid"],"szl":["chlodny"],"tr_TR":["ooguz","pultars"],"uk":["androsua","olexn","twixi007"],"uk_UA":["astalavister"],"zh_CN":["anolir","jxj2zzz79pfp9bpo","linuxbckp","mofitt2016","oksjd","phy","spice2wolf"],"zh_TW":["linuxbckp","mofitt2016","phy","waiabsfabuloushk"]}
|
||||
@@ -1,85 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.app.Application
|
||||
import android.os.StrictMode
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.syncadapter.AccountsUpdatedListener
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.davdroid.ui.UiUtils
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import java.util.logging.Level
|
||||
import javax.inject.Inject
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
@HiltAndroidApp
|
||||
class App: Application(), Thread.UncaughtExceptionHandler, Configuration.Provider {
|
||||
|
||||
@Inject lateinit var accountsUpdatedListener: AccountsUpdatedListener
|
||||
@Inject lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Logger.initialize(this)
|
||||
|
||||
if (BuildConfig.DEBUG)
|
||||
// debug builds
|
||||
StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder()
|
||||
.detectActivityLeaks()
|
||||
.detectFileUriExposure()
|
||||
.detectLeakedClosableObjects()
|
||||
.detectLeakedRegistrationObjects()
|
||||
.detectLeakedSqlLiteObjects()
|
||||
.penaltyLog()
|
||||
.build())
|
||||
else // if (BuildConfig.FLAVOR == FLAVOR_STANDARD)
|
||||
// handle uncaught exceptions in non-debug standard flavor
|
||||
Thread.setDefaultUncaughtExceptionHandler(this)
|
||||
|
||||
NotificationUtils.createChannels(this)
|
||||
|
||||
// set light/dark mode
|
||||
UiUtils.updateTheme(this) // when this is called in the asynchronous thread below, it recreates
|
||||
// some current activity and causes an IllegalStateException in rare cases
|
||||
|
||||
// don't block UI for some background checks
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
thread {
|
||||
// watch for account changes/deletions
|
||||
accountsUpdatedListener.listen()
|
||||
|
||||
// watch installed/removed tasks apps over whole app lifetime and update sync settings accordingly
|
||||
TasksAppWatcher.watchInstalledTaskApps(this, GlobalScope)
|
||||
|
||||
// create/update app shortcuts
|
||||
UiUtils.updateShortcuts(this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun uncaughtException(t: Thread, e: Throwable) {
|
||||
Logger.log.log(Level.SEVERE, "Unhandled exception!", e)
|
||||
|
||||
val intent = DebugInfoActivity.IntentBuilder(this)
|
||||
.withCause(e)
|
||||
.newTask()
|
||||
.build()
|
||||
startActivity(intent)
|
||||
|
||||
exitProcess(1)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
|
||||
/**
|
||||
* Brand-specific constants like (non-theme) colors, homepage URLs etc.
|
||||
*/
|
||||
object Constants {
|
||||
|
||||
const val DAVDROID_GREEN_RGBA = 0xFF8bc34a.toInt()
|
||||
|
||||
val HOMEPAGE_URL = "https://www.davx5.com".toUri()
|
||||
const val HOMEPAGE_PATH_FAQ = "faq"
|
||||
const val HOMEPAGE_PATH_FAQ_SYNC_NOT_RUN = "synchronization-is-not-run-as-expected"
|
||||
const val HOMEPAGE_PATH_FAQ_LOCATION_PERMISSION = "wifi-ssid-restriction-location-permission"
|
||||
const val HOMEPAGE_PATH_OPEN_SOURCE = "donate"
|
||||
const val HOMEPAGE_PATH_PRIVACY = "privacy"
|
||||
const val HOMEPAGE_PATH_TESTED_SERVICES = "tested-with"
|
||||
|
||||
val MANUAL_URL = "https://manual.davx5.com".toUri()
|
||||
const val MANUAL_PATH_ACCOUNTS_COLLECTIONS = "accounts_collections.html"
|
||||
const val MANUAL_FRAGMENT_SERVICE_DISCOVERY = "how-does-service-discovery-work"
|
||||
const val MANUAL_PATH_WEBDAV_MOUNTS = "webdav_mounts.html"
|
||||
|
||||
val COMMUNITY_URL = "https://github.com/bitfireAT/davx5-ose/discussions".toUri()
|
||||
|
||||
val FEDIVERSE_HANDLE = "@davx5app@fosstodon.org"
|
||||
val FEDIVERSE_URL = "https://fosstodon.org/@davx5app".toUri()
|
||||
|
||||
/**
|
||||
* Appends query parameters for anonymized usage statistics (app ID, version).
|
||||
* Can be used by the called Website to get an idea of which versions etc. are currently used.
|
||||
*
|
||||
* @param context optional info about from where the URL was opened (like a specific Activity)
|
||||
*/
|
||||
fun Uri.Builder.withStatParams(context: String? = null): Uri.Builder {
|
||||
appendQueryParameter("pk_campaign", BuildConfig.APPLICATION_ID)
|
||||
appendQueryParameter("app-version", BuildConfig.VERSION_NAME)
|
||||
|
||||
if (context != null)
|
||||
appendQueryParameter("pk_kwd", context)
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.util.TaskUtils
|
||||
import at.bitfire.davdroid.util.packageChangedFlow
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Watches whether a tasks app has been installed or uninstalled and updates
|
||||
* the selected tasks app and task sync settings accordingly.
|
||||
*/
|
||||
object TasksAppWatcher {
|
||||
|
||||
fun watchInstalledTaskApps(context: Context, externalScope: CoroutineScope) {
|
||||
externalScope.launch(Dispatchers.Default) {
|
||||
packageChangedFlow(context).collect {
|
||||
onPackageChanged(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPackageChanged(context: Context) {
|
||||
val currentProvider = TaskUtils.currentProvider(context)
|
||||
Logger.log.info("App launched or package (un)installed; current tasks provider = $currentProvider")
|
||||
|
||||
if (currentProvider == null) {
|
||||
// Iterate through all supported providers and select one, if available.
|
||||
var providerSelected = false
|
||||
for (provider in TaskProvider.ProviderName.entries) {
|
||||
val available = context.packageManager.resolveContentProvider(provider.authority, 0) != null
|
||||
if (available) {
|
||||
Logger.log.info("Selecting new tasks provider: $provider")
|
||||
TaskUtils.selectProvider(context, provider)
|
||||
providerSelected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!providerSelected)
|
||||
// no provider available (anymore), also clear setting and sync
|
||||
TaskUtils.selectProvider(context, null)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.database.sqlite.SQLiteQueryBuilder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.database.getStringOrNull
|
||||
import androidx.room.AutoMigration
|
||||
import androidx.room.Database
|
||||
import androidx.room.DeleteColumn
|
||||
import androidx.room.ProvidedAutoMigrationSpec
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.migration.AutoMigrationSpec
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.TextTable
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
|
||||
import at.bitfire.davdroid.ui.AccountsActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.io.Writer
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Suppress("ClassName")
|
||||
@Database(entities = [
|
||||
Service::class,
|
||||
HomeSet::class,
|
||||
Collection::class,
|
||||
Principal::class,
|
||||
SyncStats::class,
|
||||
WebDavDocument::class,
|
||||
WebDavMount::class
|
||||
], exportSchema = true, version = 13, autoMigrations = [
|
||||
AutoMigration(from = 9, to = 10),
|
||||
AutoMigration(from = 10, to = 11),
|
||||
AutoMigration(from = 11, to = 12, spec = AppDatabase.AutoMigration11_12::class),
|
||||
AutoMigration(from = 12, to = 13)
|
||||
])
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class AppDatabase: RoomDatabase() {
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppDatabaseModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun appDatabase(@ApplicationContext context: Context): AppDatabase =
|
||||
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "services.db")
|
||||
.addMigrations(*migrations)
|
||||
.addAutoMigrationSpec(AutoMigration11_12(context))
|
||||
.fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing
|
||||
.addCallback(object: Callback() {
|
||||
override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
|
||||
val nm = NotificationManagerCompat.from(context)
|
||||
val launcherIntent = Intent(context, AccountsActivity::class.java)
|
||||
val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_GENERAL)
|
||||
.setSmallIcon(R.drawable.ic_warning_notify)
|
||||
.setContentTitle(context.getString(R.string.database_destructive_migration_title))
|
||||
.setContentText(context.getString(R.string.database_destructive_migration_text))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0, launcherIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
nm.notifyIfPossible(NotificationUtils.NOTIFY_DATABASE_CORRUPTED, notify)
|
||||
|
||||
// remove all accounts because they're unfortunately useless without database
|
||||
val am = AccountManager.get(context)
|
||||
for (account in am.getAccountsByType(context.getString(R.string.account_type)))
|
||||
am.removeAccount(account, null, null)
|
||||
}
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
// auto migrations
|
||||
|
||||
@ProvidedAutoMigrationSpec
|
||||
@DeleteColumn(tableName = "collection", columnName = "owner")
|
||||
class AutoMigration11_12(val context: Context): AutoMigrationSpec {
|
||||
override fun onPostMigrate(db: SupportSQLiteDatabase) {
|
||||
Logger.log.info("Database update to v12, refreshing services to get display names of owners")
|
||||
db.query("SELECT id FROM service", arrayOf()).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val serviceId = cursor.getLong(0)
|
||||
RefreshCollectionsWorker.enqueue(context, serviceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
// manual migrations
|
||||
|
||||
val migrations: Array<Migration> = arrayOf(
|
||||
object : Migration(8, 9) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("CREATE TABLE syncstats (" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"collectionId INTEGER NOT NULL REFERENCES collection(id) ON DELETE CASCADE," +
|
||||
"authority TEXT NOT NULL," +
|
||||
"lastSync INTEGER NOT NULL)")
|
||||
db.execSQL("CREATE UNIQUE INDEX index_syncstats_collectionId_authority ON syncstats(collectionId, authority)")
|
||||
|
||||
db.execSQL("CREATE INDEX index_collection_url ON collection(url)")
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(7, 8) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN personal INTEGER NOT NULL DEFAULT 1")
|
||||
db.execSQL("ALTER TABLE collection ADD COLUMN homeSetId INTEGER DEFAULT NULL REFERENCES homeset(id) ON DELETE SET NULL")
|
||||
db.execSQL("ALTER TABLE collection ADD COLUMN owner TEXT DEFAULT NULL")
|
||||
db.execSQL("CREATE INDEX index_collection_homeSetId_type ON collection(homeSetId, type)")
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(6, 7) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN privBind INTEGER NOT NULL DEFAULT 1")
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN displayName TEXT DEFAULT NULL")
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(5, 6) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
val sql = arrayOf(
|
||||
// migrate "services" to "service": rename columns, make id NOT NULL
|
||||
"CREATE TABLE service(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"accountName TEXT NOT NULL," +
|
||||
"type TEXT NOT NULL," +
|
||||
"principal TEXT DEFAULT NULL" +
|
||||
")",
|
||||
"CREATE UNIQUE INDEX index_service_accountName_type ON service(accountName, type)",
|
||||
"INSERT INTO service(id, accountName, type, principal) SELECT _id, accountName, service, principal FROM services",
|
||||
"DROP TABLE services",
|
||||
|
||||
// migrate "homesets" to "homeset": rename columns, make id NOT NULL
|
||||
"CREATE TABLE homeset(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"serviceId INTEGER NOT NULL," +
|
||||
"url TEXT NOT NULL," +
|
||||
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
|
||||
")",
|
||||
"CREATE UNIQUE INDEX index_homeset_serviceId_url ON homeset(serviceId, url)",
|
||||
"INSERT INTO homeset(id, serviceId, url) SELECT _id, serviceID, url FROM homesets",
|
||||
"DROP TABLE homesets",
|
||||
|
||||
// migrate "collections" to "collection": rename columns, make id NOT NULL
|
||||
"CREATE TABLE collection(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"serviceId INTEGER NOT NULL," +
|
||||
"type TEXT NOT NULL," +
|
||||
"url TEXT NOT NULL," +
|
||||
"privWriteContent INTEGER NOT NULL DEFAULT 1," +
|
||||
"privUnbind INTEGER NOT NULL DEFAULT 1," +
|
||||
"forceReadOnly INTEGER NOT NULL DEFAULT 0," +
|
||||
"displayName TEXT DEFAULT NULL," +
|
||||
"description TEXT DEFAULT NULL," +
|
||||
"color INTEGER DEFAULT NULL," +
|
||||
"timezone TEXT DEFAULT NULL," +
|
||||
"supportsVEVENT INTEGER DEFAULT NULL," +
|
||||
"supportsVTODO INTEGER DEFAULT NULL," +
|
||||
"supportsVJOURNAL INTEGER DEFAULT NULL," +
|
||||
"source TEXT DEFAULT NULL," +
|
||||
"sync INTEGER NOT NULL DEFAULT 0," +
|
||||
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
|
||||
")",
|
||||
"CREATE INDEX index_collection_serviceId_type ON collection(serviceId,type)",
|
||||
"INSERT INTO collection(id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync) " +
|
||||
"SELECT _id, serviceID, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync FROM collections",
|
||||
"DROP TABLE collections"
|
||||
)
|
||||
sql.forEach { db.execSQL(it) }
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(4, 5) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN privWriteContent INTEGER DEFAULT 0 NOT NULL")
|
||||
db.execSQL("UPDATE collections SET privWriteContent=NOT readOnly")
|
||||
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN privUnbind INTEGER DEFAULT 0 NOT NULL")
|
||||
db.execSQL("UPDATE collections SET privUnbind=NOT readOnly")
|
||||
|
||||
// there's no DROP COLUMN in SQLite, so just keep the "readOnly" column
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(3, 4) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN forceReadOnly INTEGER DEFAULT 0 NOT NULL")
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
// We don't have access to the context in a Room migration now, so
|
||||
// we will just drop those settings from old DAVx5 versions.
|
||||
Logger.log.warning("Dropping settings distrustSystemCerts and overrideProxy*")
|
||||
|
||||
/*val edit = PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
try {
|
||||
db.query("settings", arrayOf("setting", "value"), null, null, null, null, null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
when (cursor.getString(0)) {
|
||||
"distrustSystemCerts" -> edit.putBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, cursor.getInt(1) != 0)
|
||||
"overrideProxy" -> edit.putBoolean(App.OVERRIDE_PROXY, cursor.getInt(1) != 0)
|
||||
"overrideProxyHost" -> edit.putString(App.OVERRIDE_PROXY_HOST, cursor.getString(1))
|
||||
"overrideProxyPort" -> edit.putInt(App.OVERRIDE_PROXY_PORT, cursor.getInt(1))
|
||||
|
||||
StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED ->
|
||||
edit.putBoolean(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED, cursor.getInt(1) != 0)
|
||||
StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED ->
|
||||
edit.putBoolean(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED, cursor.getInt(1) != 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
db.execSQL("DROP TABLE settings")
|
||||
} finally {
|
||||
edit.apply()
|
||||
}*/
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN type TEXT NOT NULL DEFAULT ''")
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN source TEXT DEFAULT NULL")
|
||||
db.execSQL("UPDATE collections SET type=(" +
|
||||
"SELECT CASE service WHEN ? THEN ? ELSE ? END " +
|
||||
"FROM services WHERE _id=collections.serviceID" +
|
||||
")",
|
||||
arrayOf("caldav", "CALENDAR", "ADDRESS_BOOK"))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
||||
// DAOs
|
||||
|
||||
abstract fun serviceDao(): ServiceDao
|
||||
abstract fun homeSetDao(): HomeSetDao
|
||||
abstract fun collectionDao(): CollectionDao
|
||||
abstract fun principalDao(): PrincipalDao
|
||||
abstract fun syncStatsDao(): SyncStatsDao
|
||||
abstract fun webDavDocumentDao(): WebDavDocumentDao
|
||||
abstract fun webDavMountDao(): WebDavMountDao
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
fun dump(writer: Writer, ignoreTables: Array<String>) {
|
||||
val db = openHelper.readableDatabase
|
||||
db.beginTransactionNonExclusive()
|
||||
|
||||
// iterate through all tables
|
||||
db.query(SQLiteQueryBuilder.buildQueryString(false, "sqlite_master", arrayOf("name"), "type='table'", null, null, null, null)).use { cursorTables ->
|
||||
while (cursorTables.moveToNext()) {
|
||||
val tableName = cursorTables.getString(0)
|
||||
if (ignoreTables.contains(tableName)) {
|
||||
writer.append("$tableName: ")
|
||||
db.query("SELECT COUNT(*) FROM $tableName").use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
writer.append("${cursor.getInt(0)} row(s), data not listed here\n\n")
|
||||
}
|
||||
} else {
|
||||
writer.append("$tableName\n")
|
||||
db.query("SELECT * FROM $tableName").use { cursor ->
|
||||
val table = TextTable(*cursor.columnNames)
|
||||
val cols = cursor.columnCount
|
||||
// print rows
|
||||
while (cursor.moveToNext()) {
|
||||
val values = Array(cols) { idx -> cursor.getStringOrNull(idx) }
|
||||
table.addLine(*values)
|
||||
}
|
||||
writer.append(table.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.db
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface WebDavMountDao {
|
||||
|
||||
@Delete
|
||||
fun delete(mount: WebDavMount)
|
||||
|
||||
@Query("SELECT * FROM webdav_mount ORDER BY name, url")
|
||||
fun getAll(): List<WebDavMount>
|
||||
|
||||
@Query("SELECT * FROM webdav_mount ORDER BY name, url")
|
||||
fun getAllLive(): LiveData<List<WebDavMount>>
|
||||
|
||||
@Query("SELECT * FROM webdav_mount WHERE id=:id")
|
||||
fun getById(id: Long): WebDavMount
|
||||
|
||||
@Insert
|
||||
fun insert(mount: WebDavMount): Long
|
||||
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
import android.util.Log
|
||||
|
||||
import org.apache.commons.lang3.math.NumberUtils
|
||||
|
||||
import java.util.logging.Handler
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.LogRecord
|
||||
|
||||
object LogcatHandler: Handler() {
|
||||
|
||||
private const val MAX_LINE_LENGTH = 3000
|
||||
|
||||
init {
|
||||
formatter = PlainTextFormatter.LOGCAT
|
||||
level = Level.ALL
|
||||
}
|
||||
|
||||
override fun publish(r: LogRecord) {
|
||||
val text = formatter.format(r)
|
||||
val level = r.level.intValue()
|
||||
|
||||
val end = text.length
|
||||
var pos = 0
|
||||
while (pos < end) {
|
||||
val line = text.substring(pos, NumberUtils.min(pos + MAX_LINE_LENGTH, end))
|
||||
when {
|
||||
level >= Level.SEVERE.intValue() -> Log.e(r.loggerName, line)
|
||||
level >= Level.WARNING.intValue() -> Log.w(r.loggerName, line)
|
||||
level >= Level.CONFIG.intValue() -> Log.i(r.loggerName, line)
|
||||
level >= Level.FINER.intValue() -> Log.d(r.loggerName, line)
|
||||
else -> Log.v(r.loggerName, line)
|
||||
}
|
||||
pos += MAX_LINE_LENGTH
|
||||
}
|
||||
}
|
||||
|
||||
override fun flush() {}
|
||||
override fun close() {}
|
||||
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
import android.app.Application
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Process
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.AppSettingsActivity
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.Date
|
||||
import java.util.logging.FileHandler
|
||||
import java.util.logging.Level
|
||||
|
||||
object Logger : SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
private const val LOGGER_NAME = "davx5"
|
||||
const val LOG_TO_FILE = "log_to_file"
|
||||
|
||||
val log: java.util.logging.Logger = java.util.logging.Logger.getLogger(LOGGER_NAME)
|
||||
|
||||
private lateinit var context: Application
|
||||
private lateinit var preferences: SharedPreferences
|
||||
|
||||
|
||||
fun initialize(app: Application) {
|
||||
context = app
|
||||
preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
preferences.registerOnSharedPreferenceChangeListener(this)
|
||||
|
||||
reinitialize()
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
|
||||
if (key == LOG_TO_FILE) {
|
||||
log.info("Logging settings changed; re-initializing logger")
|
||||
reinitialize()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun reinitialize() {
|
||||
val logToFile = preferences.getBoolean(LOG_TO_FILE, false)
|
||||
val logVerbose = logToFile || BuildConfig.DEBUG || Log.isLoggable(log.name, Log.DEBUG)
|
||||
|
||||
log.info("Verbose logging: $logVerbose; to file: $logToFile")
|
||||
|
||||
// set logging level according to preferences
|
||||
val rootLogger = java.util.logging.Logger.getLogger("")
|
||||
rootLogger.level = if (logVerbose) Level.ALL else Level.INFO
|
||||
|
||||
// reset all handlers and add our own logcat handler
|
||||
rootLogger.useParentHandlers = false
|
||||
rootLogger.handlers.forEach { handler ->
|
||||
rootLogger.removeHandler(handler)
|
||||
if (handler is FileHandler) // gracefully close previous verbose-logging FileHandlers
|
||||
handler.close()
|
||||
}
|
||||
rootLogger.addHandler(LogcatHandler)
|
||||
|
||||
val nm = NotificationManagerCompat.from(context)
|
||||
// log to external file according to preferences
|
||||
if (logToFile) {
|
||||
val logFile = getDebugLogFile() ?: return log.warning("Log file could not be retrieved.")
|
||||
if (logFile.createNewFile())
|
||||
logFile.writeText("Log file created at ${Date()}; PID ${Process.myPid()}; UID ${Process.myUid()}\n")
|
||||
|
||||
try {
|
||||
val fileHandler = FileHandler(logFile.toString(), true).apply {
|
||||
formatter = PlainTextFormatter.DEFAULT
|
||||
}
|
||||
rootLogger.addHandler(fileHandler)
|
||||
log.info("Now logging to file: $logFile")
|
||||
|
||||
val builder = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_DEBUG)
|
||||
builder .setSmallIcon(R.drawable.ic_sd_card_notify)
|
||||
.setContentTitle(context.getString(R.string.app_settings_logging))
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setContentText(context.getString(R.string.logging_notification_text, context.getString(R.string.app_name)))
|
||||
.setOngoing(true)
|
||||
|
||||
val shareIntent = DebugInfoActivity.IntentBuilder(context)
|
||||
.newTask()
|
||||
.share()
|
||||
val pendingShare = PendingIntent.getActivity(context, 0, shareIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
builder.addAction(NotificationCompat.Action.Builder(
|
||||
R.drawable.ic_share,
|
||||
context.getString(R.string.logging_notification_view_share),
|
||||
pendingShare
|
||||
).build())
|
||||
|
||||
val prefIntent = Intent(context, AppSettingsActivity::class.java)
|
||||
prefIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
val pendingPref = PendingIntent.getActivity(context, 0, prefIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
builder.addAction(NotificationCompat.Action.Builder(
|
||||
R.drawable.ic_settings,
|
||||
context.getString(R.string.logging_notification_disable),
|
||||
pendingPref
|
||||
).build())
|
||||
|
||||
nm.notifyIfPossible(NotificationUtils.NOTIFY_VERBOSE_LOGGING, builder.build())
|
||||
} catch(e: IOException) {
|
||||
log.log(Level.SEVERE, "Couldn't create log file", e)
|
||||
Toast.makeText(context, context.getString(R.string.logging_couldnt_create_file), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
} else {
|
||||
// verbose logging is disabled -> cancel notification and remove old logs
|
||||
nm.cancel(NotificationUtils.NOTIFY_VERBOSE_LOGGING)
|
||||
debugDir()?.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates (when necessary) and returns the directory where all the debug files (such as log files) are stored.
|
||||
* Must match the contents of `res/xml/debug.paths.xml`.
|
||||
*
|
||||
* @return The directory where all debug info are stored, or `null` if the directory couldn't be created successfully.
|
||||
*/
|
||||
fun debugDir(): File? {
|
||||
val dir = File(context.filesDir, "debug")
|
||||
if (dir.exists() && dir.isDirectory)
|
||||
return dir
|
||||
|
||||
if (dir.mkdir())
|
||||
return dir
|
||||
|
||||
Toast.makeText(context, context.getString(R.string.logging_couldnt_create_file), Toast.LENGTH_LONG).show()
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The file (in [debugDir]) where verbose logs are stored.
|
||||
*
|
||||
* @return The file where verbose logs are stored, or `null` if there's no [debugDir].
|
||||
*/
|
||||
fun getDebugLogFile(): File? {
|
||||
val logDir = debugDir() ?: return null
|
||||
return File(logDir, "davx5-log.txt")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.apache.commons.lang3.time.DateFormatUtils
|
||||
import java.util.Locale
|
||||
import java.util.logging.Formatter
|
||||
import java.util.logging.LogRecord
|
||||
|
||||
class PlainTextFormatter private constructor(
|
||||
private val logcat: Boolean
|
||||
): Formatter() {
|
||||
|
||||
companion object {
|
||||
val LOGCAT = PlainTextFormatter(true)
|
||||
val DEFAULT = PlainTextFormatter(false)
|
||||
|
||||
const val MAX_MESSAGE_LENGTH = 20000
|
||||
}
|
||||
|
||||
override fun format(r: LogRecord): String {
|
||||
val builder = StringBuilder()
|
||||
|
||||
if (!logcat)
|
||||
builder .append(DateFormatUtils.format(r.millis, "yyyy-MM-dd HH:mm:ss", Locale.ROOT))
|
||||
.append(" ").append(r.threadID).append(" ")
|
||||
|
||||
val className = shortClassName(r.sourceClassName)
|
||||
if (className != r.loggerName)
|
||||
builder.append("[").append(className).append("] ")
|
||||
|
||||
builder.append(StringUtils.abbreviate(r.message, MAX_MESSAGE_LENGTH))
|
||||
|
||||
r.thrown?.let {
|
||||
builder .append("\nEXCEPTION ")
|
||||
.append(ExceptionUtils.getStackTrace(it))
|
||||
}
|
||||
|
||||
r.parameters?.let {
|
||||
for ((idx, param) in it.withIndex())
|
||||
builder.append("\n\tPARAMETER #").append(idx).append(" = ").append(param)
|
||||
}
|
||||
|
||||
if (!logcat)
|
||||
builder.append("\n")
|
||||
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
private fun shortClassName(className: String) = className
|
||||
.replace(Regex("^at\\.bitfire\\.(dav|cert4an|dav4an|ical4an|vcard4an)droid\\."), "")
|
||||
.replace(Regex("\\$.*$"), "")
|
||||
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
import java.util.logging.Handler
|
||||
import java.util.logging.LogRecord
|
||||
|
||||
class StringHandler: Handler() {
|
||||
|
||||
val builder = StringBuilder()
|
||||
|
||||
init {
|
||||
formatter = PlainTextFormatter.DEFAULT
|
||||
}
|
||||
|
||||
override fun publish(record: LogRecord) {
|
||||
builder.append(formatter.format(record))
|
||||
}
|
||||
|
||||
override fun flush() {}
|
||||
override fun close() {}
|
||||
|
||||
override fun toString() = builder.toString()
|
||||
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.openid.appauth.AuthState
|
||||
import net.openid.appauth.AuthorizationException
|
||||
import net.openid.appauth.AuthorizationService
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Sends an OAuth Bearer token authorization as described in RFC 6750.
|
||||
*/
|
||||
class BearerAuthInterceptor(
|
||||
private val accessToken: String
|
||||
): Interceptor {
|
||||
|
||||
companion object {
|
||||
|
||||
fun fromAuthState(authService: AuthorizationService, authState: AuthState, callback: AuthStateUpdateCallback? = null): BearerAuthInterceptor? {
|
||||
return runBlocking {
|
||||
val accessTokenFuture = CompletableDeferred<String>()
|
||||
|
||||
authState.performActionWithFreshTokens(authService) { accessToken: String?, _: String?, ex: AuthorizationException? ->
|
||||
if (accessToken != null) {
|
||||
// persist updated AuthState
|
||||
callback?.onUpdate(authState)
|
||||
|
||||
// emit access token
|
||||
accessTokenFuture.complete(accessToken)
|
||||
}
|
||||
else {
|
||||
Logger.log.log(Level.WARNING, "Couldn't obtain access token", ex)
|
||||
accessTokenFuture.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// return value
|
||||
try {
|
||||
BearerAuthInterceptor(accessTokenFuture.await())
|
||||
} catch (ignored: CancellationException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
Logger.log.finer("Authenticating request with access token")
|
||||
val rq = chain.request().newBuilder()
|
||||
.header("Authorization", "Bearer $accessToken")
|
||||
.build()
|
||||
return chain.proceed(rq)
|
||||
}
|
||||
|
||||
|
||||
fun interface AuthStateUpdateCallback {
|
||||
fun onUpdate(authState: AuthState)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import java.util.logging.Level
|
||||
|
||||
object ConnectionUtils {
|
||||
|
||||
/**
|
||||
* Checks whether we are connected to validated WiFi
|
||||
*/
|
||||
internal fun wifiAvailable(connectivityManager: ConnectivityManager): Boolean {
|
||||
connectivityManager.allNetworks.forEach { network ->
|
||||
connectivityManager.getNetworkCapabilities(network)?.let { capabilities ->
|
||||
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
|
||||
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether we are connected to the Internet.
|
||||
*
|
||||
* On API 26+ devices, if a VPN is used, WorkManager might start the SyncWorker without an
|
||||
* Internet connection (because [NetworkCapabilities.NET_CAPABILITY_VALIDATED] is always set for VPN connections).
|
||||
* To prevent the start without internet access, we don't check for VPN connections by default
|
||||
* (by using [NetworkCapabilities.NET_CAPABILITY_NOT_VPN]).
|
||||
*
|
||||
* However in special occasions (when syncing over a VPN without validated Internet on the
|
||||
* underlying connection) we do not want to exclude VPNs.
|
||||
*
|
||||
* @param ignoreVpns *true* filters VPN connections in the Internet check; *false* allows them as valid connection
|
||||
* @return whether we are connected to the Internet
|
||||
*/
|
||||
internal fun internetAvailable(connectivityManager: ConnectivityManager, ignoreVpns: Boolean): Boolean {
|
||||
return connectivityManager.allNetworks.any { network ->
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(network)
|
||||
Logger.log.log(Level.FINE, "Looking for validated Internet over this connection.",
|
||||
arrayOf(connectivityManager.getNetworkInfo(network), capabilities))
|
||||
|
||||
if (capabilities != null) {
|
||||
if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) {
|
||||
Logger.log.fine("Missing network capability: INTERNET")
|
||||
return@any false
|
||||
}
|
||||
|
||||
if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) {
|
||||
Logger.log.fine("Missing network capability: VALIDATED")
|
||||
return@any false
|
||||
}
|
||||
|
||||
if (ignoreVpns)
|
||||
if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) {
|
||||
Logger.log.fine("Missing network capability: NOT_VPN")
|
||||
return@any false
|
||||
}
|
||||
|
||||
Logger.log.fine("This connection can be used.")
|
||||
/* return@any */ true
|
||||
} else
|
||||
// no network capabilities available, we can't use this connection
|
||||
/* return@any */ false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.security.KeyChain
|
||||
import at.bitfire.cert4android.CustomCertManager
|
||||
import at.bitfire.dav4jvm.BasicDigestAuthHandler
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import net.openid.appauth.AuthState
|
||||
import net.openid.appauth.AuthorizationService
|
||||
import okhttp3.Cache
|
||||
import okhttp3.ConnectionSpec
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttp
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Response
|
||||
import okhttp3.brotli.BrotliInterceptor
|
||||
import okhttp3.internal.tls.OkHostnameVerifier
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import java.io.File
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.net.Socket
|
||||
import java.security.KeyStore
|
||||
import java.security.Principal
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Level
|
||||
import javax.net.ssl.KeyManager
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509ExtendedKeyManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
class HttpClient private constructor(
|
||||
val okHttpClient: OkHttpClient,
|
||||
private var authService: AuthorizationService? = null
|
||||
): AutoCloseable {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface HttpClientEntryPoint {
|
||||
fun authorizationService(): AuthorizationService
|
||||
fun settingsManager(): SettingsManager
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** max. size of disk cache (10 MB) */
|
||||
const val DISK_CACHE_MAX_SIZE: Long = 10*1024*1024
|
||||
|
||||
/** Base Builder to build all clients from. Use rarely; [OkHttpClient]s should
|
||||
* be reused as much as possible. */
|
||||
fun baseBuilder() =
|
||||
OkHttpClient.Builder()
|
||||
// Set timeouts. According to [AbstractThreadedSyncAdapter], when there is no network
|
||||
// traffic within a minute, a sync will be cancelled.
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.pingInterval(
|
||||
45,
|
||||
TimeUnit.SECONDS
|
||||
) // avoid cancellation because of missing traffic; only works for HTTP/2
|
||||
|
||||
// keep TLS 1.0 and 1.1 for now; remove when major browsers have dropped it (probably 2020)
|
||||
.connectionSpecs(
|
||||
listOf(
|
||||
ConnectionSpec.CLEARTEXT,
|
||||
ConnectionSpec.COMPATIBLE_TLS
|
||||
)
|
||||
)
|
||||
|
||||
// don't allow redirects by default, because it would break PROPFIND handling
|
||||
.followRedirects(false)
|
||||
|
||||
// add User-Agent to every request
|
||||
.addInterceptor(UserAgentInterceptor)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
authService?.dispose()
|
||||
okHttpClient.cache?.close()
|
||||
}
|
||||
|
||||
|
||||
class Builder(
|
||||
val context: Context,
|
||||
accountSettings: AccountSettings? = null,
|
||||
val logger: java.util.logging.Logger? = Logger.log,
|
||||
val loggerLevel: HttpLoggingInterceptor.Level = HttpLoggingInterceptor.Level.BODY
|
||||
) {
|
||||
|
||||
fun interface CertManagerProducer {
|
||||
fun certManager(): CustomCertManager
|
||||
}
|
||||
|
||||
private var appInForeground: MutableStateFlow<Boolean>? =
|
||||
MutableStateFlow(false)
|
||||
private var authService: AuthorizationService? = null
|
||||
private var certManagerProducer: CertManagerProducer? = null
|
||||
private var certificateAlias: String? = null
|
||||
private var offerCompression: Boolean = false
|
||||
|
||||
// default cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
|
||||
private var cookieStore: CookieJar? = MemoryCookieStore()
|
||||
|
||||
private val orig = baseBuilder()
|
||||
|
||||
init {
|
||||
// add network logging, if requested
|
||||
if (logger != null && logger.isLoggable(Level.FINEST)) {
|
||||
val loggingInterceptor = HttpLoggingInterceptor { message -> logger.finest(message) }
|
||||
loggingInterceptor.level = loggerLevel
|
||||
orig.addNetworkInterceptor(loggingInterceptor)
|
||||
}
|
||||
|
||||
val settings = EntryPointAccessors.fromApplication(context, HttpClientEntryPoint::class.java).settingsManager()
|
||||
|
||||
// custom proxy support
|
||||
try {
|
||||
val proxyTypeValue = settings.getInt(Settings.PROXY_TYPE)
|
||||
if (proxyTypeValue != Settings.PROXY_TYPE_SYSTEM) {
|
||||
// we set our own proxy
|
||||
val address by lazy { // lazy because not required for PROXY_TYPE_NONE
|
||||
InetSocketAddress(
|
||||
settings.getString(Settings.PROXY_HOST),
|
||||
settings.getInt(Settings.PROXY_PORT)
|
||||
)
|
||||
}
|
||||
val proxy =
|
||||
when (proxyTypeValue) {
|
||||
Settings.PROXY_TYPE_NONE -> Proxy.NO_PROXY
|
||||
Settings.PROXY_TYPE_HTTP -> Proxy(Proxy.Type.HTTP, address)
|
||||
Settings.PROXY_TYPE_SOCKS -> Proxy(Proxy.Type.SOCKS, address)
|
||||
else -> throw IllegalArgumentException("Invalid proxy type")
|
||||
}
|
||||
orig.proxy(proxy)
|
||||
Logger.log.log(Level.INFO, "Using proxy setting", proxy)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Can't set proxy, ignoring", e)
|
||||
}
|
||||
|
||||
customCertManager {
|
||||
// by default, use a CustomCertManager that respects the "distrust system certificates" setting
|
||||
val trustSystemCerts = !settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES)
|
||||
CustomCertManager(context, trustSystemCerts, appInForeground)
|
||||
}
|
||||
|
||||
// use account settings for authentication and cookies
|
||||
if (accountSettings != null)
|
||||
addAuthentication(null, accountSettings.credentials(), authStateCallback = { authState: AuthState ->
|
||||
accountSettings.credentials(Credentials(authState = authState))
|
||||
})
|
||||
}
|
||||
|
||||
constructor(context: Context, host: String?, credentials: Credentials?) : this(context) {
|
||||
if (credentials != null)
|
||||
addAuthentication(host, credentials)
|
||||
}
|
||||
|
||||
fun addAuthentication(host: String?, credentials: Credentials, insecurePreemptive: Boolean = false, authStateCallback: BearerAuthInterceptor.AuthStateUpdateCallback? = null): Builder {
|
||||
if (credentials.username != null && credentials.password != null) {
|
||||
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), credentials.username, credentials.password, insecurePreemptive)
|
||||
orig.addNetworkInterceptor(authHandler)
|
||||
.authenticator(authHandler)
|
||||
}
|
||||
|
||||
if (credentials.certificateAlias != null)
|
||||
certificateAlias = credentials.certificateAlias
|
||||
|
||||
credentials.authState?.let { authState ->
|
||||
val newAuthService = EntryPointAccessors.fromApplication(context, HttpClientEntryPoint::class.java).authorizationService()
|
||||
authService = newAuthService
|
||||
BearerAuthInterceptor.fromAuthState(newAuthService, authState, authStateCallback)?.let { bearerAuthInterceptor ->
|
||||
orig.addNetworkInterceptor(bearerAuthInterceptor)
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun allowCompression(allow: Boolean): Builder {
|
||||
offerCompression = allow
|
||||
return this
|
||||
}
|
||||
|
||||
fun cookieStore(store: CookieJar?): Builder {
|
||||
cookieStore = store
|
||||
return this
|
||||
}
|
||||
|
||||
fun followRedirects(follow: Boolean): Builder {
|
||||
orig.followRedirects(follow)
|
||||
return this
|
||||
}
|
||||
|
||||
fun customCertManager(producer: CertManagerProducer) {
|
||||
certManagerProducer = producer
|
||||
}
|
||||
fun setForeground(foreground: Boolean): Builder {
|
||||
appInForeground?.value = foreground
|
||||
return this
|
||||
}
|
||||
|
||||
fun withDiskCache(): Builder {
|
||||
for (dir in arrayOf(context.externalCacheDir, context.cacheDir).filterNotNull()) {
|
||||
if (dir.exists() && dir.canWrite()) {
|
||||
val cacheDir = File(dir, "HttpClient")
|
||||
cacheDir.mkdir()
|
||||
Logger.log.fine("Using disk cache: $cacheDir")
|
||||
orig.cache(Cache(cacheDir, DISK_CACHE_MAX_SIZE))
|
||||
break
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun build(): HttpClient {
|
||||
cookieStore?.let {
|
||||
orig.cookieJar(it)
|
||||
}
|
||||
|
||||
if (offerCompression)
|
||||
// offer Brotli and gzip compression
|
||||
orig.addInterceptor(BrotliInterceptor)
|
||||
|
||||
var keyManager: KeyManager? = null
|
||||
certificateAlias?.let { alias ->
|
||||
// get provider certificate and private key
|
||||
val certs = KeyChain.getCertificateChain(context, alias) ?: return@let
|
||||
val key = KeyChain.getPrivateKey(context, alias) ?: return@let
|
||||
logger?.fine("Using provider certificate $alias for authentication (chain length: ${certs.size})")
|
||||
|
||||
// create KeyManager
|
||||
keyManager = object : X509ExtendedKeyManager() {
|
||||
override fun getServerAliases(p0: String?, p1: Array<out Principal>?): Array<String>? = null
|
||||
override fun chooseServerAlias(p0: String?, p1: Array<out Principal>?, p2: Socket?) = null
|
||||
|
||||
override fun getClientAliases(p0: String?, p1: Array<out Principal>?) =
|
||||
arrayOf(alias)
|
||||
|
||||
override fun chooseClientAlias(p0: Array<out String>?, p1: Array<out Principal>?, p2: Socket?) =
|
||||
alias
|
||||
|
||||
override fun getCertificateChain(forAlias: String?) =
|
||||
certs.takeIf { forAlias == alias }
|
||||
|
||||
override fun getPrivateKey(forAlias: String?) =
|
||||
key.takeIf { forAlias == alias }
|
||||
}
|
||||
|
||||
// HTTP/2 doesn't support client certificates (yet)
|
||||
// see https://tools.ietf.org/html/draft-ietf-httpbis-http2-secondary-certs-04
|
||||
orig.protocols(listOf(Protocol.HTTP_1_1))
|
||||
}
|
||||
|
||||
if (certManagerProducer != null || keyManager != null) {
|
||||
val manager = certManagerProducer?.certManager()
|
||||
|
||||
val trustManager = manager ?: /* fall back to system default trust manager */
|
||||
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
.let { factory ->
|
||||
factory.init(null as KeyStore?)
|
||||
factory.trustManagers.first() as X509TrustManager
|
||||
}
|
||||
|
||||
val hostnameVerifier =
|
||||
if (manager != null)
|
||||
manager.HostnameVerifier(OkHostnameVerifier)
|
||||
else
|
||||
OkHostnameVerifier
|
||||
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(
|
||||
if (keyManager != null) arrayOf(keyManager) else null,
|
||||
arrayOf(trustManager),
|
||||
null)
|
||||
orig.sslSocketFactory(sslContext.socketFactory, trustManager)
|
||||
orig.hostnameVerifier(hostnameVerifier)
|
||||
}
|
||||
|
||||
return HttpClient(orig.build(), authService = authService)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
object UserAgentInterceptor: Interceptor {
|
||||
|
||||
// use Locale.ROOT because numbers may be encoded as non-ASCII characters in other locales
|
||||
private val userAgentDateFormat = SimpleDateFormat("yyyy/MM/dd", Locale.ROOT)
|
||||
private val userAgentDate = userAgentDateFormat.format(Date(BuildConfig.buildTime))
|
||||
val userAgent = "${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4jvm; " +
|
||||
"okhttp/${OkHttp.VERSION}) Android/${Build.VERSION.RELEASE}"
|
||||
|
||||
init {
|
||||
Logger.log.info("Will set \"User-Agent: $userAgent\" for further requests")
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val locale = Locale.getDefault()
|
||||
val request = chain.request().newBuilder()
|
||||
.header("User-Agent", userAgent)
|
||||
.header("Accept-Language", "${locale.language}-${locale.country}, ${locale.language};q=0.7, *;q=0.5")
|
||||
.build()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.network
|
||||
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
import org.apache.commons.collections4.keyvalue.MultiKey
|
||||
import org.apache.commons.collections4.map.HashedMap
|
||||
import org.apache.commons.collections4.map.MultiKeyMap
|
||||
import java.util.LinkedList
|
||||
|
||||
/**
|
||||
* Primitive cookie store that stores cookies in a (volatile) hash map.
|
||||
* Will be sufficient for session cookies.
|
||||
*/
|
||||
class MemoryCookieStore: CookieJar {
|
||||
|
||||
/**
|
||||
* Stored cookies. The multi-key consists of three parts: name, domain, and path.
|
||||
* This ensures that cookies can be overwritten. [RFC 6265 5.3 Storage Model]
|
||||
* Not thread-safe!
|
||||
*/
|
||||
private val storage = MultiKeyMap.multiKeyMap(HashedMap<MultiKey<out String>, Cookie>())!!
|
||||
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
synchronized(storage) {
|
||||
for (cookie in cookies)
|
||||
storage.put(cookie.name, cookie.domain, cookie.path, cookie)
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
val cookies = LinkedList<Cookie>()
|
||||
|
||||
synchronized(storage) {
|
||||
val iter = storage.mapIterator()
|
||||
while (iter.hasNext()) {
|
||||
iter.next()
|
||||
val cookie = iter.value
|
||||
|
||||
// remove expired cookies
|
||||
if (cookie.expiresAt <= System.currentTimeMillis()) {
|
||||
iter.remove()
|
||||
continue
|
||||
}
|
||||
|
||||
// add applicable cookies
|
||||
if (cookie.matches(url))
|
||||
cookies += cookie
|
||||
}
|
||||
}
|
||||
|
||||
return cookies
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,445 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.RemoteException
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import android.provider.ContactsContract.Groups
|
||||
import android.provider.ContactsContract.RawContacts
|
||||
import android.util.Base64
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.syncadapter.AccountUtils
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import at.bitfire.davdroid.util.setAndVerifyUserData
|
||||
import at.bitfire.vcard4android.AndroidAddressBook
|
||||
import at.bitfire.vcard4android.AndroidContact
|
||||
import at.bitfire.vcard4android.AndroidGroup
|
||||
import at.bitfire.vcard4android.Constants
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.LinkedList
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* A local address book. Requires an own Android account, because Android manages contacts per
|
||||
* account and there is no such thing as "address books". So, DAVx5 creates a "DAVx5
|
||||
* address book" account for every CardDAV address book. These accounts are bound to a
|
||||
* DAVx5 main account.
|
||||
*/
|
||||
open class LocalAddressBook(
|
||||
private val context: Context,
|
||||
account: Account,
|
||||
provider: ContentProviderClient?
|
||||
): AndroidAddressBook<LocalContact, LocalGroup>(account, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalAddress> {
|
||||
|
||||
companion object {
|
||||
|
||||
const val USER_DATA_MAIN_ACCOUNT_TYPE = "real_account_type"
|
||||
const val USER_DATA_MAIN_ACCOUNT_NAME = "real_account_name"
|
||||
const val USER_DATA_URL = "url"
|
||||
const val USER_DATA_READ_ONLY = "read_only"
|
||||
|
||||
/**
|
||||
* Creates a local address book.
|
||||
*
|
||||
* @param context app context to resolve string resources
|
||||
* @param provider contacts provider client
|
||||
* @param mainAccount main account this address book (account) belongs to
|
||||
* @param info collection where to take the name and settings from
|
||||
* @param forceReadOnly `true`: set the address book to "force read-only"; `false`: determine read-only flag from [info]
|
||||
*/
|
||||
fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, info: Collection, forceReadOnly: Boolean): LocalAddressBook {
|
||||
val account = Account(accountName(mainAccount, info), context.getString(R.string.account_type_address_book))
|
||||
val userData = initialUserData(mainAccount, info.url.toString())
|
||||
Logger.log.log(Level.INFO, "Creating local address book $account", userData)
|
||||
if (!AccountUtils.createAccount(context, account, userData))
|
||||
throw IllegalStateException("Couldn't create address book account")
|
||||
|
||||
val addressBook = LocalAddressBook(context, account, provider)
|
||||
addressBook.updateSyncFrameworkSettings()
|
||||
|
||||
// initialize Contacts Provider Settings
|
||||
val values = ContentValues(2)
|
||||
values.put(ContactsContract.Settings.SHOULD_SYNC, 1)
|
||||
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
|
||||
addressBook.settings = values
|
||||
addressBook.readOnly = forceReadOnly || !info.privWriteContent || info.forceReadOnly
|
||||
|
||||
return addressBook
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds and returns all the local address books belonging to a given main account
|
||||
*
|
||||
* @param mainAccount the main account to use
|
||||
* @return list of [mainAccount]'s address books
|
||||
*/
|
||||
fun findAll(context: Context, provider: ContentProviderClient?, mainAccount: Account) = AccountManager.get(context)
|
||||
.getAccountsByType(context.getString(R.string.account_type_address_book))
|
||||
.map { LocalAddressBook(context, it, provider) }
|
||||
.filter {
|
||||
try {
|
||||
it.mainAccount == mainAccount
|
||||
} catch(e: IllegalArgumentException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
|
||||
fun accountName(mainAccount: Account, info: Collection): String {
|
||||
val baos = ByteArrayOutputStream()
|
||||
baos.write(info.url.hashCode())
|
||||
val hash = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)
|
||||
|
||||
val sb = StringBuilder(info.displayName.let {
|
||||
if (it.isNullOrEmpty())
|
||||
DavUtils.lastSegmentOfUrl(info.url)
|
||||
else
|
||||
it
|
||||
})
|
||||
sb.append(" (${mainAccount.name} $hash)")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
fun initialUserData(mainAccount: Account, url: String): Bundle {
|
||||
val bundle = Bundle(3)
|
||||
bundle.putString(USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
|
||||
bundle.putString(USER_DATA_MAIN_ACCOUNT_TYPE, mainAccount.type)
|
||||
bundle.putString(USER_DATA_URL, url)
|
||||
return bundle
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds and returns the main account of the given address book's account (sub-account)
|
||||
*
|
||||
* @param account the address book account to find the main account for
|
||||
*
|
||||
* @return the associated main account, `null` if none can be found (e.g. when main account has been deleted)
|
||||
*
|
||||
* @throws IllegalArgumentException when [account] is not an address book account
|
||||
*/
|
||||
fun mainAccount(context: Context, account: Account): Account? =
|
||||
if (account.type == context.getString(R.string.account_type_address_book)) {
|
||||
val manager = AccountManager.get(context)
|
||||
val accountName = manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME)
|
||||
val accountType = manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE)
|
||||
if (accountName != null && accountType != null)
|
||||
Account(accountName, accountType)
|
||||
else
|
||||
null
|
||||
} else
|
||||
throw IllegalArgumentException("$account is not an address book account")
|
||||
|
||||
}
|
||||
|
||||
override val tag: String
|
||||
get() = "contacts-${account.name}"
|
||||
|
||||
override val title = account.name!!
|
||||
|
||||
/**
|
||||
* Whether contact groups ([LocalGroup]) are included in query results
|
||||
* and are affected by updates/deletes on generic members.
|
||||
*
|
||||
* For instance, if groupMethod is GROUP_VCARDS, [findDirty] will find only dirty [LocalContact]s,
|
||||
* but if it is enabled, [findDirty] will find dirty [LocalContact]s and [LocalGroup]s.
|
||||
*/
|
||||
open val groupMethod: GroupMethod by lazy {
|
||||
val accountSettings = AccountSettings(context, requireMainAccount())
|
||||
accountSettings.getGroupMethod()
|
||||
}
|
||||
val includeGroups
|
||||
get() = groupMethod == GroupMethod.GROUP_VCARDS
|
||||
|
||||
private var _mainAccount: Account? = null
|
||||
/**
|
||||
* The associated main account which this address book's accounts belong to.
|
||||
*
|
||||
* @throws IllegalArgumentException when [account] is not an address book account or when no main account is assigned
|
||||
*/
|
||||
open var mainAccount: Account?
|
||||
get() {
|
||||
_mainAccount?.let { return it }
|
||||
|
||||
val result = mainAccount(context, account)
|
||||
_mainAccount = result
|
||||
return result
|
||||
}
|
||||
set(newMainAccount) {
|
||||
if (newMainAccount == null)
|
||||
throw IllegalArgumentException("Main account must not be null")
|
||||
|
||||
AccountManager.get(context).let { accountManager ->
|
||||
accountManager.setAndVerifyUserData(account, USER_DATA_MAIN_ACCOUNT_NAME, newMainAccount.name)
|
||||
accountManager.setAndVerifyUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE, newMainAccount.type)
|
||||
}
|
||||
|
||||
_mainAccount = newMainAccount
|
||||
}
|
||||
fun requireMainAccount(): Account =
|
||||
mainAccount ?: throw IllegalArgumentException("No main account assigned to address book $account")
|
||||
|
||||
var url: String
|
||||
get() = AccountManager.get(context).getUserData(account, USER_DATA_URL)
|
||||
?: throw IllegalStateException("Address book has no URL")
|
||||
set(url) = AccountManager.get(context).setAndVerifyUserData(account, USER_DATA_URL, url)
|
||||
|
||||
override var readOnly: Boolean
|
||||
get() = AccountManager.get(context).getUserData(account, USER_DATA_READ_ONLY) != null
|
||||
set(readOnly) = AccountManager.get(context).setAndVerifyUserData(account, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
|
||||
|
||||
override var lastSyncState: SyncState?
|
||||
get() = syncState?.let { SyncState.fromString(String(it)) }
|
||||
set(state) {
|
||||
syncState = state?.toString()?.toByteArray()
|
||||
}
|
||||
|
||||
|
||||
/* operations on the collection (address book) itself */
|
||||
|
||||
override fun markNotDirty(flags: Int): Int {
|
||||
val values = ContentValues(1)
|
||||
values.put(LocalContact.COLUMN_FLAGS, flags)
|
||||
var number = provider!!.update(rawContactsSyncUri(), values, "${RawContacts.DIRTY}=0", null)
|
||||
|
||||
if (includeGroups) {
|
||||
values.clear()
|
||||
values.put(LocalGroup.COLUMN_FLAGS, flags)
|
||||
number += provider!!.update(groupsSyncUri(), values, "NOT ${Groups.DIRTY}", null)
|
||||
}
|
||||
|
||||
return number
|
||||
}
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int): Int {
|
||||
var number = provider!!.delete(rawContactsSyncUri(),
|
||||
"NOT ${RawContacts.DIRTY} AND ${LocalContact.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
|
||||
|
||||
if (includeGroups)
|
||||
number += provider!!.delete(groupsSyncUri(),
|
||||
"NOT ${Groups.DIRTY} AND ${LocalGroup.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
|
||||
|
||||
return number
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the address book settings.
|
||||
*
|
||||
* @param info collection where to take the settings from
|
||||
* @param forceReadOnly `true`: set the address book to "force read-only"; `false`: determine read-only flag from [info]
|
||||
*/
|
||||
fun update(info: Collection, forceReadOnly: Boolean) {
|
||||
val newAccountName = accountName(requireMainAccount(), info)
|
||||
|
||||
if (account.name != newAccountName) {
|
||||
// no need to re-assign contacts to new account, because they will be deleted by contacts provider in any case
|
||||
val accountManager = AccountManager.get(context)
|
||||
val future = accountManager.renameAccount(account, newAccountName, null, null)
|
||||
account = future.result
|
||||
}
|
||||
|
||||
val nowReadOnly = forceReadOnly || !info.privWriteContent || info.forceReadOnly
|
||||
if (nowReadOnly != readOnly) {
|
||||
Constants.log.info("Address book now read-only = $nowReadOnly, updating contacts")
|
||||
|
||||
// update address book itself
|
||||
readOnly = nowReadOnly
|
||||
|
||||
// update raw contacts
|
||||
val rawContactValues = ContentValues(1)
|
||||
rawContactValues.put(RawContacts.RAW_CONTACT_IS_READ_ONLY, if (nowReadOnly) 1 else 0)
|
||||
provider!!.update(rawContactsSyncUri(), rawContactValues, null, null)
|
||||
|
||||
// update data rows
|
||||
val dataValues = ContentValues(1)
|
||||
dataValues.put(ContactsContract.Data.IS_READ_ONLY, if (nowReadOnly) 1 else 0)
|
||||
provider!!.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null)
|
||||
|
||||
// update group rows
|
||||
val groupValues = ContentValues(1)
|
||||
groupValues.put(Groups.GROUP_IS_READ_ONLY, if (nowReadOnly) 1 else 0)
|
||||
provider!!.update(groupsSyncUri(), groupValues, null, null)
|
||||
}
|
||||
|
||||
// make sure it will still be synchronized when contacts are updated
|
||||
updateSyncFrameworkSettings()
|
||||
}
|
||||
|
||||
fun delete() {
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.removeAccount(account, null, null, null)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Updates the sync framework settings for this address book:
|
||||
*
|
||||
* - Contacts sync of this address book account shall be possible -> isSyncable = 1
|
||||
* - When a contact is changed, a sync shall be initiated -> syncAutomatically = true
|
||||
* - Remove unwanted sync framework periodic syncs created by setSyncAutomatically, as
|
||||
* we use PeriodicSyncWorker for scheduled syncs
|
||||
*/
|
||||
fun updateSyncFrameworkSettings() {
|
||||
// Enable sync-ability
|
||||
if (ContentResolver.getIsSyncable(account, ContactsContract.AUTHORITY) != 1)
|
||||
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1)
|
||||
|
||||
// Enable content trigger
|
||||
if (!ContentResolver.getSyncAutomatically(account, ContactsContract.AUTHORITY))
|
||||
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
|
||||
|
||||
// Remove periodic syncs (setSyncAutomatically also creates periodic syncs, which we don't want)
|
||||
for (periodicSync in ContentResolver.getPeriodicSyncs(account, ContactsContract.AUTHORITY))
|
||||
ContentResolver.removePeriodicSync(periodicSync.account, periodicSync.authority, periodicSync.extras)
|
||||
}
|
||||
|
||||
|
||||
/* operations on members (contacts/groups) */
|
||||
|
||||
override fun findByName(name: String): LocalAddress? {
|
||||
val result = queryContacts("${AndroidContact.COLUMN_FILENAME}=?", arrayOf(name)).firstOrNull()
|
||||
return if (includeGroups)
|
||||
result ?: queryGroups("${AndroidGroup.COLUMN_FILENAME}=?", arrayOf(name)).firstOrNull()
|
||||
else
|
||||
result
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns an array of local contacts/groups which have been deleted locally. (DELETED != 0).
|
||||
* @throws RemoteException on content provider errors
|
||||
*/
|
||||
override fun findDeleted() =
|
||||
if (includeGroups)
|
||||
findDeletedContacts() + findDeletedGroups()
|
||||
else
|
||||
findDeletedContacts()
|
||||
|
||||
fun findDeletedContacts() = queryContacts(RawContacts.DELETED, null)
|
||||
fun findDeletedGroups() = queryGroups(Groups.DELETED, null)
|
||||
|
||||
/**
|
||||
* Returns an array of local contacts/groups which have been changed locally (DIRTY != 0).
|
||||
* @throws RemoteException on content provider errors
|
||||
*/
|
||||
override fun findDirty() =
|
||||
if (includeGroups)
|
||||
findDirtyContacts() + findDirtyGroups()
|
||||
else
|
||||
findDirtyContacts()
|
||||
fun findDirtyContacts() = queryContacts(RawContacts.DIRTY, null)
|
||||
fun findDirtyGroups() = queryGroups(Groups.DIRTY, null)
|
||||
|
||||
override fun forgetETags() {
|
||||
if (includeGroups) {
|
||||
val values = ContentValues(1)
|
||||
values.putNull(AndroidGroup.COLUMN_ETAG)
|
||||
provider!!.update(groupsSyncUri(), values, null, null)
|
||||
}
|
||||
val values = ContentValues(1)
|
||||
values.putNull(AndroidContact.COLUMN_ETAG)
|
||||
provider!!.update(rawContactsSyncUri(), values, null, null)
|
||||
}
|
||||
|
||||
|
||||
fun getContactIdsByGroupMembership(groupId: Long): List<Long> {
|
||||
val ids = LinkedList<Long>()
|
||||
provider!!.query(syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(GroupMembership.RAW_CONTACT_ID),
|
||||
"(${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?)",
|
||||
arrayOf(GroupMembership.CONTENT_ITEM_TYPE, groupId.toString()), null)?.use { cursor ->
|
||||
while (cursor.moveToNext())
|
||||
ids += cursor.getLong(0)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
fun getContactUidFromId(contactId: Long): String? {
|
||||
provider!!.query(rawContactsSyncUri(), arrayOf(AndroidContact.COLUMN_UID),
|
||||
"${RawContacts._ID}=?", arrayOf(contactId.toString()), null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return cursor.getString(0)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Queries all contacts with DIRTY flag and checks whether their data checksum has changed, i.e.
|
||||
* if they're "really dirty" (= data has changed, not only metadata, which is not hashed).
|
||||
* The DIRTY flag is removed from contacts which are not "really dirty", i.e. from contacts
|
||||
* whose contact data checksum has not changed.
|
||||
* @return number of "really dirty" contacts
|
||||
* @throws RemoteException on content provider errors
|
||||
*/
|
||||
fun verifyDirty(): Int {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
throw IllegalStateException("verifyDirty() should not be called on Android != 7.0")
|
||||
|
||||
var reallyDirty = 0
|
||||
for (contact in findDirtyContacts()) {
|
||||
val lastHash = contact.getLastHashCode()
|
||||
val currentHash = contact.dataHashCode()
|
||||
if (lastHash == currentHash) {
|
||||
// hash is code still the same, contact is not "really dirty" (only metadata been have changed)
|
||||
Logger.log.log(Level.FINE, "Contact data hash has not changed, resetting dirty flag", contact)
|
||||
contact.resetDirty()
|
||||
} else {
|
||||
Logger.log.log(Level.FINE, "Contact data has changed from hash $lastHash to $currentHash", contact)
|
||||
reallyDirty++
|
||||
}
|
||||
}
|
||||
|
||||
if (includeGroups)
|
||||
reallyDirty += findDirtyGroups().size
|
||||
|
||||
return reallyDirty
|
||||
}
|
||||
|
||||
|
||||
/* special group operations */
|
||||
|
||||
/**
|
||||
* Finds the first group with the given title. If there is no group with this
|
||||
* title, a new group is created.
|
||||
* @param title title of the group to look for
|
||||
* @return id of the group with given title
|
||||
* @throws RemoteException on content provider errors
|
||||
*/
|
||||
fun findOrCreateGroup(title: String): Long {
|
||||
provider!!.query(syncAdapterURI(Groups.CONTENT_URI), arrayOf(Groups._ID),
|
||||
"${Groups.TITLE}=?", arrayOf(title), null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return cursor.getLong(0)
|
||||
}
|
||||
|
||||
val values = ContentValues(1)
|
||||
values.put(Groups.TITLE, title)
|
||||
val uri = provider!!.insert(syncAdapterURI(Groups.CONTENT_URI), values) ?: throw RemoteException("Couldn't create contact group")
|
||||
return ContentUris.parseId(uri)
|
||||
}
|
||||
|
||||
fun removeEmptyGroups() {
|
||||
// find groups without members
|
||||
/** should be done using {@link Groups.SUMMARY_COUNT}, but it's not implemented in Android yet */
|
||||
queryGroups(null, null).filter { it.getMembers().isEmpty() }.forEach { group ->
|
||||
Logger.log.log(Level.FINE, "Deleting group", group)
|
||||
group.delete()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
import android.provider.CalendarContract.Calendars
|
||||
import android.provider.CalendarContract.Events
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.AndroidCalendarFactory
|
||||
import at.bitfire.ical4android.BatchOperation
|
||||
import at.bitfire.ical4android.util.DateUtils
|
||||
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
|
||||
import java.util.LinkedList
|
||||
import java.util.logging.Level
|
||||
|
||||
class LocalCalendar private constructor(
|
||||
account: Account,
|
||||
provider: ContentProviderClient,
|
||||
id: Long
|
||||
): AndroidCalendar<LocalEvent>(account, provider, LocalEvent.Factory, id), LocalCollection<LocalEvent> {
|
||||
|
||||
companion object {
|
||||
|
||||
private const val COLUMN_SYNC_STATE = Calendars.CAL_SYNC1
|
||||
|
||||
fun create(account: Account, provider: ContentProviderClient, info: Collection): Uri {
|
||||
val values = valuesFromCollectionInfo(info, true)
|
||||
|
||||
// ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash.
|
||||
values.put(Calendars.ACCOUNT_NAME, account.name)
|
||||
values.put(Calendars.ACCOUNT_TYPE, account.type)
|
||||
|
||||
// Email address for scheduling. Used by the calendar provider to determine whether the
|
||||
// user is ORGANIZER/ATTENDEE for a certain event.
|
||||
values.put(Calendars.OWNER_ACCOUNT, account.name)
|
||||
|
||||
// flag as visible & synchronizable at creation, might be changed by user at any time
|
||||
values.put(Calendars.VISIBLE, 1)
|
||||
values.put(Calendars.SYNC_EVENTS, 1)
|
||||
return create(account, provider, values)
|
||||
}
|
||||
|
||||
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
|
||||
val values = ContentValues()
|
||||
values.put(Calendars.NAME, info.url.toString())
|
||||
values.put(Calendars.CALENDAR_DISPLAY_NAME, if (info.displayName.isNullOrBlank()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
|
||||
|
||||
if (withColor)
|
||||
values.put(Calendars.CALENDAR_COLOR, info.color ?: Constants.DAVDROID_GREEN_RGBA)
|
||||
|
||||
if (info.privWriteContent && !info.forceReadOnly) {
|
||||
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER)
|
||||
values.put(Calendars.CAN_MODIFY_TIME_ZONE, 1)
|
||||
values.put(Calendars.CAN_ORGANIZER_RESPOND, 1)
|
||||
} else
|
||||
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ)
|
||||
|
||||
info.timezone?.let { tzData ->
|
||||
try {
|
||||
val timeZone = DateUtils.parseVTimeZone(tzData)
|
||||
timeZone.timeZoneId?.let { tzId ->
|
||||
values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(tzId.value))
|
||||
}
|
||||
} catch(e: IllegalArgumentException) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't parse calendar default time zone", e)
|
||||
}
|
||||
}
|
||||
|
||||
// add base values for Calendars
|
||||
values.putAll(calendarBaseValues)
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override val tag: String
|
||||
get() = "events-${account.name}-$id"
|
||||
|
||||
override val title: String
|
||||
get() = displayName ?: id.toString()
|
||||
|
||||
private var accessLevel: Int = Calendars.CAL_ACCESS_OWNER // assume full access if not specified
|
||||
override val readOnly
|
||||
get() = accessLevel <= Calendars.CAL_ACCESS_READ
|
||||
|
||||
override var lastSyncState: SyncState?
|
||||
get() = provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return SyncState.fromString(cursor.getString(0))
|
||||
else
|
||||
null
|
||||
}
|
||||
set(state) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_SYNC_STATE, state.toString())
|
||||
provider.update(calendarSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
|
||||
override fun populate(info: ContentValues) {
|
||||
super.populate(info)
|
||||
accessLevel = info.getAsInteger(Calendars.CALENDAR_ACCESS_LEVEL) ?: Calendars.CAL_ACCESS_OWNER
|
||||
}
|
||||
|
||||
fun update(info: Collection, updateColor: Boolean) =
|
||||
update(valuesFromCollectionInfo(info, updateColor))
|
||||
|
||||
|
||||
override fun findDeleted() =
|
||||
queryEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
|
||||
|
||||
override fun findDirty(): List<LocalEvent> {
|
||||
val dirty = LinkedList<LocalEvent>()
|
||||
|
||||
/*
|
||||
* RFC 5545 3.8.7.4. Sequence Number
|
||||
* When a calendar component is created, its sequence number is 0. It is monotonically incremented by the "Organizer's"
|
||||
* CUA each time the "Organizer" makes a significant revision to the calendar component.
|
||||
*/
|
||||
for (localEvent in queryEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null)) {
|
||||
try {
|
||||
val event = requireNotNull(localEvent.event)
|
||||
|
||||
val nonGroupScheduled = event.attendees.isEmpty()
|
||||
val weAreOrganizer = localEvent.weAreOrganizer
|
||||
|
||||
val sequence = event.sequence
|
||||
if (sequence == null)
|
||||
// sequence has not been assigned yet (i.e. this event was just locally created)
|
||||
event.sequence = 0
|
||||
else if (nonGroupScheduled || weAreOrganizer) // increase sequence
|
||||
event.sequence = sequence + 1
|
||||
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't check/increase sequence", e)
|
||||
}
|
||||
dirty += localEvent
|
||||
}
|
||||
|
||||
return dirty
|
||||
}
|
||||
|
||||
override fun findByName(name: String) =
|
||||
queryEvents("${Events._SYNC_ID}=?", arrayOf(name)).firstOrNull()
|
||||
|
||||
|
||||
override fun markNotDirty(flags: Int): Int {
|
||||
val values = ContentValues(1)
|
||||
values.put(LocalEvent.COLUMN_FLAGS, flags)
|
||||
return provider.update(Events.CONTENT_URI.asSyncAdapter(account), values,
|
||||
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int): Int {
|
||||
var deleted = 0
|
||||
// list all non-dirty events with the given flags and delete every row + its exceptions
|
||||
provider.query(Events.CONTENT_URI.asSyncAdapter(account), arrayOf(Events._ID),
|
||||
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL AND ${LocalEvent.COLUMN_FLAGS}=?",
|
||||
arrayOf(id.toString(), flags.toString()), null)?.use { cursor ->
|
||||
val batch = BatchOperation(provider)
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(0)
|
||||
// delete event and possible exceptions (content provider doesn't delete exceptions itself)
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newDelete(Events.CONTENT_URI.asSyncAdapter(account))
|
||||
.withSelection("${Events._ID}=? OR ${Events.ORIGINAL_ID}=?", arrayOf(id.toString(), id.toString())))
|
||||
}
|
||||
deleted = batch.commit()
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
|
||||
override fun forgetETags() {
|
||||
val values = ContentValues(1)
|
||||
values.putNull(LocalEvent.COLUMN_ETAG)
|
||||
provider.update(Events.CONTENT_URI.asSyncAdapter(account), values, "${Events.CALENDAR_ID}=?",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
|
||||
fun processDirtyExceptions() {
|
||||
// process deleted exceptions
|
||||
Logger.log.info("Processing deleted exceptions")
|
||||
provider.query(
|
||||
Events.CONTENT_URI.asSyncAdapter(account),
|
||||
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
|
||||
"${Events.CALENDAR_ID}=? AND ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NOT NULL",
|
||||
arrayOf(id.toString()), null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
Logger.log.fine("Found deleted exception, removing and re-scheduling original event (if available)")
|
||||
val id = cursor.getLong(0) // can't be null (by definition)
|
||||
val originalID = cursor.getLong(1) // can't be null (by query)
|
||||
|
||||
val batch = BatchOperation(provider)
|
||||
|
||||
// get original event's SEQUENCE
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(account),
|
||||
arrayOf(LocalEvent.COLUMN_SEQUENCE),
|
||||
null, null, null)?.use { cursor2 ->
|
||||
if (cursor2.moveToNext()) {
|
||||
// original event is available
|
||||
val originalSequence = if (cursor2.isNull(0)) 0 else cursor2.getInt(0)
|
||||
|
||||
// re-schedule original event and set it to DIRTY
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(account))
|
||||
.withValue(LocalEvent.COLUMN_SEQUENCE, originalSequence + 1)
|
||||
.withValue(Events.DIRTY, 1))
|
||||
}
|
||||
}
|
||||
|
||||
// completely remove deleted exception
|
||||
batch.enqueue(BatchOperation.CpoBuilder.newDelete(ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(account)))
|
||||
batch.commit()
|
||||
}
|
||||
}
|
||||
|
||||
// process dirty exceptions
|
||||
Logger.log.info("Processing dirty exceptions")
|
||||
provider.query(
|
||||
Events.CONTENT_URI.asSyncAdapter(account),
|
||||
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
|
||||
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NOT NULL",
|
||||
arrayOf(id.toString()), null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
Logger.log.fine("Found dirty exception, increasing SEQUENCE to re-schedule")
|
||||
val id = cursor.getLong(0) // can't be null (by definition)
|
||||
val originalID = cursor.getLong(1) // can't be null (by query)
|
||||
val sequence = if (cursor.isNull(2)) 0 else cursor.getInt(2)
|
||||
|
||||
val batch = BatchOperation(provider)
|
||||
// original event to DIRTY
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(account))
|
||||
.withValue(Events.DIRTY, 1))
|
||||
// increase SEQUENCE and set DIRTY to 0
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(account))
|
||||
.withValue(LocalEvent.COLUMN_SEQUENCE, sequence + 1)
|
||||
.withValue(Events.DIRTY, 0))
|
||||
batch.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks dirty events (which are not already marked as deleted) which got no valid instances as "deleted"
|
||||
*
|
||||
* @return number of affected events
|
||||
*/
|
||||
fun deleteDirtyEventsWithoutInstances() {
|
||||
provider.query(
|
||||
Events.CONTENT_URI.asSyncAdapter(account),
|
||||
arrayOf(Events._ID),
|
||||
"${Events.DIRTY} AND NOT ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", // Get dirty main events (and no exception events)
|
||||
null, null
|
||||
)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val eventID = cursor.getLong(0)
|
||||
|
||||
// get number of instances
|
||||
val numEventInstances = LocalEvent.numInstances(provider, account, eventID)
|
||||
|
||||
// delete event if there are no instances
|
||||
if (numEventInstances == 0) {
|
||||
Logger.log.info("Marking event #$eventID without instances as deleted")
|
||||
LocalEvent.markAsDeleted(provider, account, eventID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
object Factory: AndroidCalendarFactory<LocalCalendar> {
|
||||
|
||||
override fun newInstance(account: Account, provider: ContentProviderClient, id: Long) =
|
||||
LocalCalendar(account, provider, id)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.os.Build
|
||||
import android.os.RemoteException
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import android.provider.ContactsContract.RawContacts.Data
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.resource.contactrow.CachedGroupMembershipHandler
|
||||
import at.bitfire.davdroid.resource.contactrow.GroupMembershipBuilder
|
||||
import at.bitfire.davdroid.resource.contactrow.GroupMembershipHandler
|
||||
import at.bitfire.davdroid.resource.contactrow.UnknownPropertiesBuilder
|
||||
import at.bitfire.davdroid.resource.contactrow.UnknownPropertiesHandler
|
||||
import at.bitfire.vcard4android.AndroidAddressBook
|
||||
import at.bitfire.vcard4android.AndroidContact
|
||||
import at.bitfire.vcard4android.AndroidContactFactory
|
||||
import at.bitfire.vcard4android.BatchOperation
|
||||
import at.bitfire.vcard4android.CachedGroupMembership
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import ezvcard.Ezvcard
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.UUID
|
||||
|
||||
class LocalContact: AndroidContact, LocalAddress {
|
||||
|
||||
override val addressBook: LocalAddressBook
|
||||
get() = super.addressBook as LocalAddressBook
|
||||
|
||||
companion object {
|
||||
init {
|
||||
Contact.productID = "+//IDN bitfire.at//${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ez-vcard/" + Ezvcard.VERSION
|
||||
}
|
||||
|
||||
const val COLUMN_FLAGS = ContactsContract.RawContacts.SYNC4
|
||||
const val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3
|
||||
}
|
||||
|
||||
internal val cachedGroupMemberships = HashSet<Long>()
|
||||
internal val groupMemberships = HashSet<Long>()
|
||||
|
||||
override var scheduleTag: String?
|
||||
get() = null
|
||||
set(_) = throw NotImplementedError()
|
||||
|
||||
override var flags: Int = 0
|
||||
|
||||
|
||||
constructor(addressBook: LocalAddressBook, values: ContentValues): super(addressBook, values) {
|
||||
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
|
||||
}
|
||||
|
||||
constructor(addressBook: LocalAddressBook, contact: Contact, fileName: String?, eTag: String?, _flags: Int): super(addressBook, contact, fileName, eTag) {
|
||||
flags = _flags
|
||||
}
|
||||
|
||||
init {
|
||||
processor.registerHandler(CachedGroupMembershipHandler(this))
|
||||
processor.registerHandler(GroupMembershipHandler(this))
|
||||
processor.registerHandler(UnknownPropertiesHandler)
|
||||
processor.registerBuilderFactory(GroupMembershipBuilder.Factory(addressBook))
|
||||
processor.registerBuilderFactory(UnknownPropertiesBuilder.Factory)
|
||||
}
|
||||
|
||||
|
||||
override fun prepareForUpload(): String {
|
||||
val contact = getContact()
|
||||
val uid: String = contact.uid ?: run {
|
||||
// generate new UID
|
||||
val newUid = UUID.randomUUID().toString()
|
||||
|
||||
// update in contacts provider
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_UID, newUid)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
// update this event
|
||||
contact.uid = newUid
|
||||
|
||||
newUid
|
||||
}
|
||||
|
||||
return "$uid.vcf"
|
||||
}
|
||||
|
||||
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
|
||||
if (scheduleTag != null)
|
||||
throw IllegalArgumentException("Contacts must not have a Schedule-Tag")
|
||||
|
||||
val values = ContentValues(4)
|
||||
if (fileName != null)
|
||||
values.put(COLUMN_FILENAME, fileName)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(ContactsContract.RawContacts.DIRTY, 0)
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
val hashCode = dataHashCode()
|
||||
values.put(COLUMN_HASHCODE, hashCode)
|
||||
Logger.log.finer("Clearing dirty flag with eTag = $eTag, contact hash = $hashCode")
|
||||
}
|
||||
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
if (fileName != null)
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
val values = ContentValues(1)
|
||||
values.put(ContactsContract.Groups.DELETED, 0)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
fun resetDirty() {
|
||||
val values = ContentValues(1)
|
||||
values.put(ContactsContract.RawContacts.DIRTY, 0)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Calculates a hash code from the contact's data (VCard) and group memberships.
|
||||
* Attention: re-reads {@link #contact} from the database, discarding all changes in memory
|
||||
* @return hash code of contact data (including group memberships)
|
||||
*/
|
||||
internal fun dataHashCode(): Int {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
throw IllegalStateException("dataHashCode() should not be called on Android != 7")
|
||||
|
||||
// reset contact so that getContact() reads from database
|
||||
_contact = null
|
||||
|
||||
// groupMemberships is filled by getContact()
|
||||
val dataHash = getContact().hashCode()
|
||||
val groupHash = groupMemberships.hashCode()
|
||||
Logger.log.finest("Calculated data hash = $dataHash, group memberships hash = $groupHash")
|
||||
return dataHash xor groupHash
|
||||
}
|
||||
|
||||
fun updateHashCode(batch: BatchOperation?) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
throw IllegalStateException("updateHashCode() should not be called on Android != 7")
|
||||
|
||||
val hashCode = dataHashCode()
|
||||
Logger.log.fine("Storing contact hash = $hashCode")
|
||||
|
||||
if (batch == null) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_HASHCODE, hashCode)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
} else
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newUpdate(rawContactSyncURI())
|
||||
.withValue(COLUMN_HASHCODE, hashCode))
|
||||
}
|
||||
|
||||
fun getLastHashCode(): Int {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
throw IllegalStateException("getLastHashCode() should not be called on Android != 7")
|
||||
|
||||
addressBook.provider!!.query(rawContactSyncURI(), arrayOf(COLUMN_HASHCODE), null, null, null)?.use { c ->
|
||||
if (c.moveToNext() && !c.isNull(0))
|
||||
return c.getInt(0)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
fun addToGroup(batch: BatchOperation, groupID: Long) {
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newInsert(dataSyncURI())
|
||||
.withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
|
||||
.withValue(GroupMembership.RAW_CONTACT_ID, id)
|
||||
.withValue(GroupMembership.GROUP_ROW_ID, groupID))
|
||||
groupMemberships += groupID
|
||||
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newInsert(dataSyncURI())
|
||||
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
.withValue(CachedGroupMembership.RAW_CONTACT_ID, id)
|
||||
.withValue(CachedGroupMembership.GROUP_ID, groupID)
|
||||
)
|
||||
cachedGroupMemberships += groupID
|
||||
}
|
||||
|
||||
fun removeGroupMemberships(batch: BatchOperation) {
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newDelete(dataSyncURI())
|
||||
.withSelection(
|
||||
"${Data.RAW_CONTACT_ID}=? AND ${Data.MIMETYPE} IN (?,?)",
|
||||
arrayOf(id.toString(), GroupMembership.CONTENT_ITEM_TYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
))
|
||||
groupMemberships.clear()
|
||||
cachedGroupMemberships.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the IDs of all groups the contact was member of (cached memberships).
|
||||
* Cached memberships are kept in sync with memberships by DAVx5 and are used to determine
|
||||
* whether a membership has been deleted/added when a raw contact is dirty.
|
||||
* @return set of {@link GroupMembership#GROUP_ROW_ID} (may be empty)
|
||||
* @throws FileNotFoundException if the current contact can't be found
|
||||
* @throws RemoteException on contacts provider errors
|
||||
*/
|
||||
fun getCachedGroupMemberships(): Set<Long> {
|
||||
getContact()
|
||||
return cachedGroupMemberships
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the IDs of all groups the contact is member of.
|
||||
* @return set of {@link GroupMembership#GROUP_ROW_ID}s (may be empty)
|
||||
* @throws FileNotFoundException if the current contact can't be found
|
||||
* @throws RemoteException on contacts provider errors
|
||||
*/
|
||||
fun getGroupMemberships(): Set<Long> {
|
||||
getContact()
|
||||
return groupMemberships
|
||||
}
|
||||
|
||||
|
||||
// data rows
|
||||
override fun buildContact(builder: BatchOperation.CpoBuilder, update: Boolean) {
|
||||
builder.withValue(COLUMN_FLAGS, flags)
|
||||
super.buildContact(builder, update)
|
||||
}
|
||||
|
||||
// factory
|
||||
|
||||
object Factory: AndroidContactFactory<LocalContact> {
|
||||
override fun fromProvider(addressBook: AndroidAddressBook<LocalContact, *>, values: ContentValues) =
|
||||
LocalContact(addressBook as LocalAddressBook, values)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.Events
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.AndroidEvent
|
||||
import at.bitfire.ical4android.AndroidEventFactory
|
||||
import at.bitfire.ical4android.BatchOperation
|
||||
import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.ICalendar
|
||||
import at.bitfire.ical4android.Ical4Android
|
||||
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
|
||||
import net.fortuna.ical4j.model.property.ProdId
|
||||
import java.util.UUID
|
||||
class LocalEvent: AndroidEvent, LocalResource<Event> {
|
||||
|
||||
companion object {
|
||||
init {
|
||||
ICalendar.prodId = ProdId("${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ical4j/" + Ical4Android.ical4jVersion)
|
||||
}
|
||||
|
||||
const val COLUMN_ETAG = Events.SYNC_DATA1
|
||||
const val COLUMN_FLAGS = Events.SYNC_DATA2
|
||||
const val COLUMN_SEQUENCE = Events.SYNC_DATA3
|
||||
const val COLUMN_SCHEDULE_TAG = Events.SYNC_DATA4
|
||||
|
||||
/**
|
||||
* Marks the event as deleted
|
||||
* @param eventID
|
||||
*/
|
||||
fun markAsDeleted(provider: ContentProviderClient, account: Account, eventID: Long) {
|
||||
provider.update(
|
||||
ContentUris.withAppendedId(
|
||||
Events.CONTENT_URI,
|
||||
eventID
|
||||
).asSyncAdapter(account),
|
||||
ContentValues(1).apply {
|
||||
put(Events.DELETED, 1)
|
||||
},
|
||||
null,null
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Finds the amount of direct instances this event has (without exceptions); used by [numInstances]
|
||||
* to find the number of instances of exceptions.
|
||||
*
|
||||
* The number of returned instances may vary with the Android version.
|
||||
*
|
||||
* @return number of direct event instances (not counting instances of exceptions); *null* if
|
||||
* the number can't be determined or if the event has no last date (recurring event without last instance)
|
||||
*/
|
||||
fun numDirectInstances(provider: ContentProviderClient, account: Account, eventID: Long): Int? {
|
||||
// query event to get first and last instance
|
||||
var first: Long? = null
|
||||
var last: Long? = null
|
||||
provider.query(
|
||||
ContentUris.withAppendedId(
|
||||
Events.CONTENT_URI,
|
||||
eventID
|
||||
),
|
||||
arrayOf(Events.DTSTART, Events.LAST_DATE), null, null, null
|
||||
)?.use { cursor ->
|
||||
cursor.moveToNext()
|
||||
if (!cursor.isNull(0))
|
||||
first = cursor.getLong(0)
|
||||
if (!cursor.isNull(1))
|
||||
last = cursor.getLong(1)
|
||||
}
|
||||
// if this event doesn't have a last occurence, it's endless and always has instances
|
||||
if (first == null || last == null)
|
||||
return null
|
||||
|
||||
/* We can't use Long.MIN_VALUE and Long.MAX_VALUE because Android generates the instances
|
||||
on the fly and it doesn't accept those values. So we use the first/last actual occurence
|
||||
of the event (calculated by Android). */
|
||||
val instancesUri = CalendarContract.Instances.CONTENT_URI.asSyncAdapter(account)
|
||||
.buildUpon()
|
||||
.appendPath(first.toString()) // begin timestamp
|
||||
.appendPath(last.toString()) // end timestamp
|
||||
.build()
|
||||
|
||||
var numInstances = 0
|
||||
provider.query(
|
||||
instancesUri, null,
|
||||
"${CalendarContract.Instances.EVENT_ID}=?", arrayOf(eventID.toString()),
|
||||
null
|
||||
)?.use { cursor ->
|
||||
numInstances += cursor.count
|
||||
}
|
||||
return numInstances
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the total number of instances this event has (including instances of exceptions)
|
||||
*
|
||||
* The number of returned instances may vary with the Android version.
|
||||
*
|
||||
* @return number of direct event instances (not counting instances of exceptions); *null* if
|
||||
* the number can't be determined or if the event has no last date (recurring event without last instance)
|
||||
*/
|
||||
fun numInstances(provider: ContentProviderClient, account: Account, eventID: Long): Int? {
|
||||
// num instances of the main event
|
||||
var numInstances = numDirectInstances(provider, account, eventID) ?: return null
|
||||
|
||||
// add the number of instances of every main event's exception
|
||||
provider.query(
|
||||
Events.CONTENT_URI,
|
||||
arrayOf(Events._ID),
|
||||
"${Events.ORIGINAL_ID}=?", // get exception events of the main event
|
||||
arrayOf("$eventID"), null
|
||||
)?.use { exceptionsEventCursor ->
|
||||
while (exceptionsEventCursor.moveToNext()) {
|
||||
val exceptionEventID = exceptionsEventCursor.getLong(0)
|
||||
val exceptionInstances = numDirectInstances(provider, account, exceptionEventID)
|
||||
|
||||
if (exceptionInstances == null)
|
||||
// number of instances of exception can't be determined; so the total number of instances is also unclear
|
||||
return null
|
||||
|
||||
numInstances += exceptionInstances
|
||||
}
|
||||
}
|
||||
return numInstances
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override var fileName: String? = null
|
||||
private set
|
||||
|
||||
override var eTag: String? = null
|
||||
override var scheduleTag: String? = null
|
||||
|
||||
override var flags: Int = 0
|
||||
private set
|
||||
|
||||
var weAreOrganizer = false
|
||||
private set
|
||||
|
||||
|
||||
constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int): super(calendar, event) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.scheduleTag = scheduleTag
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
private constructor(calendar: AndroidCalendar<*>, values: ContentValues): super(calendar, values) {
|
||||
fileName = values.getAsString(Events._SYNC_ID)
|
||||
eTag = values.getAsString(COLUMN_ETAG)
|
||||
scheduleTag = values.getAsString(COLUMN_SCHEDULE_TAG)
|
||||
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
|
||||
}
|
||||
|
||||
override fun populateEvent(row: ContentValues, groupScheduled: Boolean) {
|
||||
val event = requireNotNull(event)
|
||||
event.sequence = row.getAsInteger(COLUMN_SEQUENCE)
|
||||
|
||||
val isOrganizer = row.getAsInteger(Events.IS_ORGANIZER)
|
||||
weAreOrganizer = isOrganizer != null && isOrganizer != 0
|
||||
|
||||
super.populateEvent(row, groupScheduled)
|
||||
}
|
||||
|
||||
override fun buildEvent(recurrence: Event?, builder: BatchOperation.CpoBuilder) {
|
||||
val event = requireNotNull(event)
|
||||
|
||||
val buildException = recurrence != null
|
||||
val eventToBuild = recurrence ?: event
|
||||
|
||||
builder .withValue(COLUMN_SEQUENCE, eventToBuild.sequence)
|
||||
.withValue(Events.DIRTY, 0)
|
||||
.withValue(Events.DELETED, 0)
|
||||
.withValue(COLUMN_FLAGS, flags)
|
||||
|
||||
if (buildException)
|
||||
builder .withValue(Events.ORIGINAL_SYNC_ID, fileName)
|
||||
else
|
||||
builder .withValue(Events._SYNC_ID, fileName)
|
||||
.withValue(COLUMN_ETAG, eTag)
|
||||
.withValue(COLUMN_SCHEDULE_TAG, scheduleTag)
|
||||
|
||||
super.buildEvent(recurrence, builder)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates and sets a new UID in the calendar provider, if no UID is already set.
|
||||
* It also returns the desired file name for the event for further processing in the sync algorithm.
|
||||
*
|
||||
* @return file name to use at upload
|
||||
*/
|
||||
override fun prepareForUpload(): String {
|
||||
// make sure that UID is set
|
||||
val uid: String = event!!.uid ?: run {
|
||||
// generate new UID
|
||||
val newUid = UUID.randomUUID().toString()
|
||||
|
||||
// update in calendar provider
|
||||
val values = ContentValues(1)
|
||||
values.put(Events.UID_2445, newUid)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
// update this event
|
||||
event?.uid = newUid
|
||||
|
||||
newUid
|
||||
}
|
||||
|
||||
val uidIsGoodFilename = uid.all { char ->
|
||||
// see RFC 2396 2.2
|
||||
char.isLetterOrDigit() || arrayOf( // allow letters and digits
|
||||
';',':','@','&','=','+','$',',', // allow reserved characters except '/' and '?'
|
||||
'-','_','.','!','~','*','\'','(',')' // allow unreserved characters
|
||||
).contains(char)
|
||||
}
|
||||
return if (uidIsGoodFilename)
|
||||
"$uid.ics" // use UID as file name
|
||||
else
|
||||
"${UUID.randomUUID()}.ics" // UID would be dangerous as file name, use random UUID instead
|
||||
}
|
||||
|
||||
|
||||
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
|
||||
val values = ContentValues(5)
|
||||
if (fileName != null)
|
||||
values.put(Events._SYNC_ID, fileName)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(COLUMN_SCHEDULE_TAG, scheduleTag)
|
||||
values.put(COLUMN_SEQUENCE, event!!.sequence)
|
||||
values.put(Events.DIRTY, 0)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
if (fileName != null)
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.scheduleTag = scheduleTag
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
val values = ContentValues(1).apply { put(Events.DELETED, 0) }
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
object Factory: AndroidEventFactory<LocalEvent> {
|
||||
override fun fromProvider(calendar: AndroidCalendar<*>, values: ContentValues) =
|
||||
LocalEvent(calendar, values)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Principal
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import at.bitfire.ical4android.JtxCollection
|
||||
import at.bitfire.ical4android.JtxCollectionFactory
|
||||
import at.bitfire.ical4android.JtxICalObject
|
||||
import at.techbee.jtx.JtxContract
|
||||
import java.util.logging.Level
|
||||
|
||||
class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Long):
|
||||
JtxCollection<JtxICalObject>(account, client, LocalJtxICalObject.Factory, id),
|
||||
LocalCollection<LocalJtxICalObject>{
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(account: Account, client: ContentProviderClient, info: Collection, owner: Principal?) {
|
||||
val values = valuesFromCollection(info, account, owner, true)
|
||||
create(account, client, values)
|
||||
}
|
||||
|
||||
fun valuesFromCollection(info: Collection, account: Account, owner: Principal?, withColor: Boolean) =
|
||||
ContentValues().apply {
|
||||
put(JtxContract.JtxCollection.URL, info.url.toString())
|
||||
put(JtxContract.JtxCollection.DISPLAYNAME, info.displayName ?: DavUtils.lastSegmentOfUrl(info.url))
|
||||
put(JtxContract.JtxCollection.DESCRIPTION, info.description)
|
||||
if (owner != null)
|
||||
put(JtxContract.JtxCollection.OWNER, owner.url.toString())
|
||||
else Logger.log.log(Level.SEVERE, "No collection owner given. Will create jtx collection without owner")
|
||||
put(JtxContract.JtxCollection.OWNER_DISPLAYNAME, owner?.displayName)
|
||||
if (withColor)
|
||||
put(JtxContract.JtxCollection.COLOR, info.color ?: Constants.DAVDROID_GREEN_RGBA)
|
||||
put(JtxContract.JtxCollection.SUPPORTSVEVENT, info.supportsVEVENT)
|
||||
put(JtxContract.JtxCollection.SUPPORTSVJOURNAL, info.supportsVJOURNAL)
|
||||
put(JtxContract.JtxCollection.SUPPORTSVTODO, info.supportsVTODO)
|
||||
put(JtxContract.JtxCollection.ACCOUNT_NAME, account.name)
|
||||
put(JtxContract.JtxCollection.ACCOUNT_TYPE, account.type)
|
||||
put(JtxContract.JtxCollection.READONLY, info.forceReadOnly || !info.privWriteContent)
|
||||
}
|
||||
}
|
||||
|
||||
override val readOnly: Boolean
|
||||
get() = throw NotImplementedError()
|
||||
|
||||
override val tag: String
|
||||
get() = "jtx-${account.name}-$id"
|
||||
override val title: String
|
||||
get() = displayname ?: id.toString()
|
||||
override var lastSyncState: SyncState?
|
||||
get() = SyncState.fromString(syncstate)
|
||||
set(value) { syncstate = value.toString() }
|
||||
|
||||
fun updateCollection(info: Collection, owner: Principal?, withColor: Boolean) {
|
||||
val values = valuesFromCollection(info, account, owner, withColor)
|
||||
update(values)
|
||||
}
|
||||
|
||||
override fun findDeleted(): List<LocalJtxICalObject> {
|
||||
val values = queryDeletedICalObjects()
|
||||
val localJtxICalObjects = mutableListOf<LocalJtxICalObject>()
|
||||
values.forEach {
|
||||
localJtxICalObjects.add(LocalJtxICalObject.Factory.fromProvider(this, it))
|
||||
}
|
||||
return localJtxICalObjects
|
||||
}
|
||||
|
||||
override fun findDirty(): List<LocalJtxICalObject> {
|
||||
val values = queryDirtyICalObjects()
|
||||
val localJtxICalObjects = mutableListOf<LocalJtxICalObject>()
|
||||
values.forEach {
|
||||
localJtxICalObjects.add(LocalJtxICalObject.Factory.fromProvider(this, it))
|
||||
}
|
||||
return localJtxICalObjects
|
||||
}
|
||||
|
||||
override fun findByName(name: String): LocalJtxICalObject? {
|
||||
val values = queryByFilename(name) ?: return null
|
||||
return LocalJtxICalObject.Factory.fromProvider(this, values)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds and returns a recurring instance of a [LocalJtxICalObject]
|
||||
*/
|
||||
fun findRecurring(uid: String, recurid: String, dtstart: Long): LocalJtxICalObject? {
|
||||
val values = queryRecur(uid, recurid, dtstart) ?: return null
|
||||
return LocalJtxICalObject.Factory.fromProvider(this, values)
|
||||
}
|
||||
|
||||
override fun markNotDirty(flags: Int)= updateSetFlags(flags)
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int) = deleteByFlags(flags)
|
||||
|
||||
override fun forgetETags() = updateSetETag(null)
|
||||
|
||||
|
||||
object Factory: JtxCollectionFactory<LocalJtxCollection> {
|
||||
override fun newInstance(account: Account, client: ContentProviderClient, id: Long) = LocalJtxCollection(account, client, id)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
interface LocalResource<in TData: Any> {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Resource is present on remote server. This flag is used to identify resources
|
||||
* which are not present on the remote server anymore and can be deleted at the end
|
||||
* of the synchronization.
|
||||
*/
|
||||
const val FLAG_REMOTELY_PRESENT = 1
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Unique ID which identifies the resource in the local storage. May be null if the
|
||||
* resource has not been saved yet.
|
||||
*/
|
||||
val id: Long?
|
||||
|
||||
/**
|
||||
* Remote file name for the resource, for instance `mycontact.vcf`. Also used to determine whether
|
||||
* a dirty record has just been created (in this case, [fileName] is *null*) or modified
|
||||
* (in this case, [fileName] is the remote file name).
|
||||
*/
|
||||
val fileName: String?
|
||||
|
||||
/** remote ETag for the resource */
|
||||
var eTag: String?
|
||||
|
||||
/** remote Schedule-Tag for the resource */
|
||||
var scheduleTag: String?
|
||||
|
||||
/** bitfield of flags; currently either [FLAG_REMOTELY_PRESENT] or 0 */
|
||||
val flags: Int
|
||||
|
||||
/**
|
||||
* Prepares the resource for uploading:
|
||||
*
|
||||
* 1. If the resource doesn't have an UID yet, this method generates one and writes it to the content provider.
|
||||
* 2. The new file name which can be used for the upload is derived from the UID and returned, but not
|
||||
* saved to the content provider. The sync manager is responsible for saving the file name that
|
||||
* was actually used.
|
||||
*
|
||||
* @return new file name of the resource (like "<uid>.vcf")
|
||||
*/
|
||||
fun prepareForUpload(): String
|
||||
|
||||
/**
|
||||
* Unsets the /dirty/ field of the resource. Typically used after successfully uploading a
|
||||
* locally modified resource.
|
||||
*
|
||||
* @param fileName If this argument is not *null*, [LocalResource.fileName] will be set to its value.
|
||||
* @param eTag ETag of the uploaded resource as returned by the server (null if the server didn't return one)
|
||||
* @param scheduleTag CalDAV Schedule-Tag of the uploaded resource as returned by the server (null if not applicable or if the server didn't return one)
|
||||
*/
|
||||
fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String? = null)
|
||||
|
||||
/**
|
||||
* Sets (local) flags of the resource. At the moment, the only allowed values are
|
||||
* 0 and [FLAG_REMOTELY_PRESENT].
|
||||
*/
|
||||
fun updateFlags(flags: Int)
|
||||
|
||||
|
||||
/**
|
||||
* Adds the data object to the content provider and ensures that the dirty flag is clear.
|
||||
*
|
||||
* @return content URI of the created row (e.g. event URI)
|
||||
*/
|
||||
fun add(): Uri
|
||||
|
||||
/**
|
||||
* Updates the data object in the content provider and ensures that the dirty flag is clear.
|
||||
*
|
||||
* @return content URI of the updated row (e.g. event URI)
|
||||
*/
|
||||
fun update(data: TData): Uri
|
||||
|
||||
/**
|
||||
* Deletes the data object from the content provider.
|
||||
*
|
||||
* @return number of affected rows
|
||||
*/
|
||||
fun delete(): Int
|
||||
|
||||
/**
|
||||
* Undoes deletion of the data object from the content provider.
|
||||
*/
|
||||
fun resetDeleted()
|
||||
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentValues
|
||||
import at.bitfire.ical4android.BatchOperation
|
||||
import at.bitfire.ical4android.DmfsTask
|
||||
import at.bitfire.ical4android.DmfsTaskFactory
|
||||
import at.bitfire.ical4android.DmfsTaskList
|
||||
import at.bitfire.ical4android.Ical4Android
|
||||
import at.bitfire.ical4android.Task
|
||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||
import java.util.UUID
|
||||
|
||||
class LocalTask: DmfsTask, LocalResource<Task> {
|
||||
|
||||
companion object {
|
||||
const val COLUMN_ETAG = Tasks.SYNC1
|
||||
const val COLUMN_FLAGS = Tasks.SYNC2
|
||||
}
|
||||
|
||||
override var fileName: String? = null
|
||||
|
||||
override var scheduleTag: String? = null
|
||||
override var eTag: String? = null
|
||||
|
||||
override var flags = 0
|
||||
private set
|
||||
|
||||
|
||||
constructor(taskList: DmfsTaskList<*>, task: Task, fileName: String?, eTag: String?, flags: Int)
|
||||
: super(taskList, task) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
private constructor(taskList: DmfsTaskList<*>, values: ContentValues): super(taskList) {
|
||||
id = values.getAsLong(Tasks._ID)
|
||||
fileName = values.getAsString(Tasks._SYNC_ID)
|
||||
eTag = values.getAsString(COLUMN_ETAG)
|
||||
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
|
||||
}
|
||||
|
||||
|
||||
/* process LocalTask-specific fields */
|
||||
|
||||
override fun buildTask(builder: BatchOperation.CpoBuilder, update: Boolean) {
|
||||
super.buildTask(builder, update)
|
||||
|
||||
builder .withValue(Tasks._SYNC_ID, fileName)
|
||||
.withValue(COLUMN_ETAG, eTag)
|
||||
.withValue(COLUMN_FLAGS, flags)
|
||||
}
|
||||
|
||||
|
||||
/* custom queries */
|
||||
|
||||
override fun prepareForUpload(): String {
|
||||
val uid: String = task!!.uid ?: run {
|
||||
// generate new UID
|
||||
val newUid = UUID.randomUUID().toString()
|
||||
|
||||
// update in tasks provider
|
||||
val values = ContentValues(1)
|
||||
values.put(Tasks._UID, newUid)
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null)
|
||||
|
||||
// update this task
|
||||
task!!.uid = newUid
|
||||
|
||||
newUid
|
||||
}
|
||||
|
||||
return "$uid.ics"
|
||||
}
|
||||
|
||||
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
|
||||
if (scheduleTag != null)
|
||||
Ical4Android.log.fine("Schedule-Tag for tasks not supported yet, won't save")
|
||||
|
||||
val values = ContentValues(4)
|
||||
if (fileName != null)
|
||||
values.put(Tasks._SYNC_ID, fileName)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(Tasks.SYNC_VERSION, task!!.sequence)
|
||||
values.put(Tasks._DIRTY, 0)
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null)
|
||||
|
||||
if (fileName != null)
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
if (id != null) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
|
||||
object Factory: DmfsTaskFactory<LocalTask> {
|
||||
override fun fromProvider(taskList: DmfsTaskList<*>, values: ContentValues) =
|
||||
LocalTask(taskList, values)
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import at.bitfire.ical4android.DmfsTaskList
|
||||
import at.bitfire.ical4android.DmfsTaskListFactory
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import org.dmfs.tasks.contract.TaskContract.*
|
||||
import java.util.logging.Level
|
||||
|
||||
class LocalTaskList private constructor(
|
||||
account: Account,
|
||||
provider: TaskProvider,
|
||||
id: Long
|
||||
): DmfsTaskList<LocalTask>(account, provider, LocalTask.Factory, id), LocalCollection<LocalTask> {
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(account: Account, provider: TaskProvider, info: Collection): Uri {
|
||||
val values = valuesFromCollectionInfo(info, true)
|
||||
values.put(TaskLists.OWNER, account.name)
|
||||
values.put(TaskLists.SYNC_ENABLED, 1)
|
||||
values.put(TaskLists.VISIBLE, 1)
|
||||
return create(account, provider, values)
|
||||
}
|
||||
|
||||
@SuppressLint("Recycle")
|
||||
@Throws(Exception::class)
|
||||
fun onRenameAccount(context: Context, oldName: String, newName: String) {
|
||||
TaskProvider.acquire(context)?.use { provider ->
|
||||
val values = ContentValues(1)
|
||||
values.put(Tasks.ACCOUNT_NAME, newName)
|
||||
provider.client.update(
|
||||
Tasks.getContentUri(provider.name.authority),
|
||||
values,
|
||||
"${Tasks.ACCOUNT_NAME}=?", arrayOf(oldName)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
|
||||
val values = ContentValues(3)
|
||||
values.put(TaskLists._SYNC_ID, info.url.toString())
|
||||
values.put(TaskLists.LIST_NAME, if (info.displayName.isNullOrBlank()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
|
||||
|
||||
if (withColor)
|
||||
values.put(TaskLists.LIST_COLOR, info.color ?: Constants.DAVDROID_GREEN_RGBA)
|
||||
|
||||
if (info.privWriteContent && !info.forceReadOnly)
|
||||
values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_OWNER)
|
||||
else
|
||||
values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_READ)
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private var accessLevel: Int = TaskListColumns.ACCESS_LEVEL_UNDEFINED
|
||||
override val readOnly
|
||||
get() =
|
||||
accessLevel != TaskListColumns.ACCESS_LEVEL_UNDEFINED &&
|
||||
accessLevel <= TaskListColumns.ACCESS_LEVEL_READ
|
||||
|
||||
override val tag: String
|
||||
get() = "tasks-${account.name}-$id"
|
||||
|
||||
override val title: String
|
||||
get() = name ?: id.toString()
|
||||
|
||||
override var lastSyncState: SyncState?
|
||||
get() {
|
||||
try {
|
||||
provider.client.query(taskListSyncUri(), arrayOf(TaskLists.SYNC_VERSION),
|
||||
null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
cursor.getString(0)?.let {
|
||||
return SyncState.fromString(it)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't read sync state", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
set(state) {
|
||||
val values = ContentValues(1)
|
||||
values.put(TaskLists.SYNC_VERSION, state?.toString())
|
||||
provider.client.update(taskListSyncUri(), values, null, null)
|
||||
}
|
||||
|
||||
|
||||
override fun populate(values: ContentValues) {
|
||||
super.populate(values)
|
||||
accessLevel = values.getAsInteger(TaskListColumns.ACCESS_LEVEL)
|
||||
}
|
||||
|
||||
fun update(info: Collection, updateColor: Boolean) =
|
||||
update(valuesFromCollectionInfo(info, updateColor))
|
||||
|
||||
|
||||
override fun findDeleted() = queryTasks(Tasks._DELETED, null)
|
||||
|
||||
override fun findDirty(): List<LocalTask> {
|
||||
val tasks = queryTasks(Tasks._DIRTY, null)
|
||||
for (localTask in tasks) {
|
||||
try {
|
||||
val task = requireNotNull(localTask.task)
|
||||
val sequence = task.sequence
|
||||
if (sequence == null) // sequence has not been assigned yet (i.e. this task was just locally created)
|
||||
task.sequence = 0
|
||||
else // task was modified, increase sequence
|
||||
task.sequence = sequence + 1
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't check/increase sequence", e)
|
||||
}
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
override fun findByName(name: String) =
|
||||
queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name)).firstOrNull()
|
||||
|
||||
|
||||
override fun markNotDirty(flags: Int): Int {
|
||||
val values = ContentValues(1)
|
||||
values.put(LocalTask.COLUMN_FLAGS, flags)
|
||||
return provider.client.update(tasksSyncUri(), values,
|
||||
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int) =
|
||||
provider.client.delete(tasksSyncUri(),
|
||||
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${LocalTask.COLUMN_FLAGS}=?",
|
||||
arrayOf(id.toString(), flags.toString()))
|
||||
|
||||
override fun forgetETags() {
|
||||
val values = ContentValues(1)
|
||||
values.putNull(LocalEvent.COLUMN_ETAG)
|
||||
provider.client.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
|
||||
object Factory: DmfsTaskListFactory<LocalTaskList> {
|
||||
|
||||
override fun newInstance(account: Account, provider: TaskProvider, id: Long) =
|
||||
LocalTaskList(account, provider, id)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,589 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.servicedetection
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.lifecycle.map
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.Operation
|
||||
import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.MultiResponseCallback
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.exception.UnauthorizedException
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarColor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarHomeSet
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarProxyReadFor
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarProxyWriteFor
|
||||
import at.bitfire.dav4jvm.property.caldav.Source
|
||||
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
|
||||
import at.bitfire.dav4jvm.property.carddav.SupportedAddressData
|
||||
import at.bitfire.dav4jvm.property.push.PushTransports
|
||||
import at.bitfire.dav4jvm.property.push.Topic
|
||||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.GroupMembership
|
||||
import at.bitfire.dav4jvm.property.webdav.HrefListProperty
|
||||
import at.bitfire.dav4jvm.property.webdav.Owner
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.HomeSet
|
||||
import at.bitfire.davdroid.db.Principal
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker.Companion.ARG_SERVICE_ID
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible
|
||||
import at.bitfire.davdroid.ui.account.AccountSettingsActivity
|
||||
import at.bitfire.davdroid.util.DavUtils.parent
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.logging.Level
|
||||
import kotlin.collections.*
|
||||
|
||||
/**
|
||||
* Refreshes list of home sets and their respective collections of a service type (CardDAV or CalDAV).
|
||||
* Called from UI, when user wants to refresh all collections of a service ([at.bitfire.davdroid.ui.account.CollectionsFragment]).
|
||||
*
|
||||
* Input data:
|
||||
*
|
||||
* - [ARG_SERVICE_ID]: service ID
|
||||
*
|
||||
* It queries all existing homesets and/or collections and then:
|
||||
* - updates resources with found properties (overwrites without comparing)
|
||||
* - adds resources if new ones are detected
|
||||
* - removes resources if not found 40x (delete locally)
|
||||
*
|
||||
* Expedited: yes (always initiated by user)
|
||||
*
|
||||
* Long-running: no
|
||||
*
|
||||
* @throws IllegalArgumentException when there's no service with the given service ID
|
||||
*/
|
||||
@HiltWorker
|
||||
class RefreshCollectionsWorker @AssistedInject constructor(
|
||||
@Assisted appContext: Context,
|
||||
@Assisted workerParams: WorkerParameters,
|
||||
var db: AppDatabase,
|
||||
var settings: SettingsManager
|
||||
): CoroutineWorker(appContext, workerParams) {
|
||||
|
||||
companion object {
|
||||
|
||||
const val ARG_SERVICE_ID = "serviceId"
|
||||
const val WORKER_TAG = "refreshCollectionsWorker"
|
||||
|
||||
// Collection properties to ask for in a propfind request to the Cal- or CardDAV server
|
||||
val DAV_COLLECTION_PROPERTIES = arrayOf(
|
||||
ResourceType.NAME,
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
DisplayName.NAME,
|
||||
Owner.NAME,
|
||||
AddressbookDescription.NAME, SupportedAddressData.NAME,
|
||||
CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME,
|
||||
Source.NAME,
|
||||
// WebDAV Push
|
||||
PushTransports.NAME,
|
||||
Topic.NAME
|
||||
)
|
||||
|
||||
// Principal properties to ask the server
|
||||
val DAV_PRINCIPAL_PROPERTIES = arrayOf(
|
||||
DisplayName.NAME,
|
||||
ResourceType.NAME
|
||||
)
|
||||
|
||||
/**
|
||||
* Uniquely identifies a refresh worker. Useful for stopping work, or querying its state.
|
||||
*
|
||||
* @param serviceId what service (CalDAV/CardDAV) the worker is running for
|
||||
*/
|
||||
fun workerName(serviceId: Long): String = "$WORKER_TAG-$serviceId"
|
||||
|
||||
/**
|
||||
* Requests immediate refresh of a given service. If not running already. this will enqueue
|
||||
* a [RefreshCollectionsWorker].
|
||||
*
|
||||
* @param serviceId serviceId which is to be refreshed
|
||||
* @return Pair with
|
||||
*
|
||||
* 1. worker name,
|
||||
* 2. operation of [WorkManager.enqueueUniqueWork] (can be used to wait for completion)
|
||||
*
|
||||
* @throws IllegalArgumentException when there's no service with this ID
|
||||
*/
|
||||
fun enqueue(context: Context, serviceId: Long): Pair<String, Operation> {
|
||||
val name = workerName(serviceId)
|
||||
val arguments = Data.Builder()
|
||||
.putLong(ARG_SERVICE_ID, serviceId)
|
||||
.build()
|
||||
val workRequest = OneTimeWorkRequestBuilder<RefreshCollectionsWorker>()
|
||||
.addTag(name)
|
||||
.setInputData(arguments)
|
||||
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
.build()
|
||||
|
||||
return Pair(
|
||||
name,
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
name,
|
||||
ExistingWorkPolicy.KEEP, // if refresh is already running, just continue that one
|
||||
workRequest
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Will tell whether a refresh worker with given service id and state exists
|
||||
*
|
||||
* @param workerName name of worker to find
|
||||
* @param workState state of worker to match
|
||||
* @return boolean true if worker with matching state was found
|
||||
*/
|
||||
fun exists(context: Context, workerName: String, workState: WorkInfo.State = WorkInfo.State.RUNNING) =
|
||||
WorkManager.getInstance(context).getWorkInfosForUniqueWorkLiveData(workerName).map {
|
||||
workInfoList -> workInfoList.any { workInfo -> workInfo.state == workState }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
val serviceId: Long = inputData.getLong(ARG_SERVICE_ID, -1)
|
||||
val service = db.serviceDao().get(serviceId) ?: throw IllegalArgumentException("Service #$serviceId not found")
|
||||
val account = Account(service.accountName, applicationContext.getString(R.string.account_type))
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
try {
|
||||
Logger.log.info("Refreshing ${service.type} collections of service #$service")
|
||||
|
||||
// cancel previous notification
|
||||
NotificationManagerCompat.from(applicationContext)
|
||||
.cancel(serviceId.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS)
|
||||
|
||||
// create authenticating OkHttpClient (credentials taken from account settings)
|
||||
runInterruptible {
|
||||
HttpClient.Builder(applicationContext, AccountSettings(applicationContext, account))
|
||||
.setForeground(true)
|
||||
.build().use { client ->
|
||||
val httpClient = client.okHttpClient
|
||||
val refresher = Refresher(db, service, settings, httpClient)
|
||||
|
||||
// refresh home set list (from principal url)
|
||||
service.principal?.let { principalUrl ->
|
||||
Logger.log.fine("Querying principal $principalUrl for home sets")
|
||||
refresher.discoverHomesets(principalUrl)
|
||||
}
|
||||
|
||||
// refresh home sets and their member collections
|
||||
refresher.refreshHomesetsAndTheirCollections()
|
||||
|
||||
// also refresh collections without a home set
|
||||
refresher.refreshHomelessCollections()
|
||||
|
||||
// Lastly, refresh the principals (collection owners)
|
||||
refresher.refreshPrincipals()
|
||||
}
|
||||
}
|
||||
|
||||
} catch(e: InvalidAccountException) {
|
||||
Logger.log.log(Level.SEVERE, "Invalid account", e)
|
||||
return Result.failure()
|
||||
} catch (e: UnauthorizedException) {
|
||||
Logger.log.log(Level.SEVERE, "Not authorized (anymore)", e)
|
||||
// notify that we need to re-authenticate in the account settings
|
||||
val settingsIntent = Intent(applicationContext, AccountSettingsActivity::class.java)
|
||||
.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account)
|
||||
notifyRefreshError(
|
||||
applicationContext.getString(R.string.sync_error_authentication_failed),
|
||||
settingsIntent
|
||||
)
|
||||
return Result.failure()
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't refresh collection list", e)
|
||||
|
||||
val debugIntent = DebugInfoActivity.IntentBuilder(applicationContext)
|
||||
.withCause(e)
|
||||
.withAccount(account)
|
||||
.build()
|
||||
notifyRefreshError(
|
||||
applicationContext.getString(R.string.refresh_collections_worker_refresh_couldnt_refresh),
|
||||
debugIntent
|
||||
)
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
// Success
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
/**
|
||||
* Used by WorkManager to show a foreground service notification for expedited jobs on Android <12.
|
||||
*/
|
||||
override suspend fun getForegroundInfo(): ForegroundInfo {
|
||||
val notification = NotificationUtils.newBuilder(applicationContext, NotificationUtils.CHANNEL_STATUS)
|
||||
.setSmallIcon(R.drawable.ic_foreground_notify)
|
||||
.setContentTitle(applicationContext.getString(R.string.foreground_service_notify_title))
|
||||
.setContentText(applicationContext.getString(R.string.foreground_service_notify_text))
|
||||
.setStyle(NotificationCompat.BigTextStyle())
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
return ForegroundInfo(NotificationUtils.NOTIFY_SYNC_EXPEDITED, notification)
|
||||
}
|
||||
|
||||
private fun notifyRefreshError(contentText: String, contentIntent: Intent) {
|
||||
val notify = NotificationUtils.newBuilder(applicationContext, NotificationUtils.CHANNEL_GENERAL)
|
||||
.setSmallIcon(R.drawable.ic_sync_problem_notify)
|
||||
.setContentTitle(applicationContext.getString(R.string.refresh_collections_worker_refresh_failed))
|
||||
.setContentText(contentText)
|
||||
.setContentIntent(PendingIntent.getActivity(applicationContext, 0, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||
.setSubText(account.name)
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.build()
|
||||
NotificationManagerCompat.from(applicationContext)
|
||||
.notifyIfPossible(serviceId.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS, notify)
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains the methods, which do the actual refreshing work. Collected here for testability
|
||||
*/
|
||||
class Refresher(
|
||||
val db: AppDatabase,
|
||||
val service: Service,
|
||||
val settings: SettingsManager,
|
||||
val httpClient: OkHttpClient
|
||||
) {
|
||||
|
||||
val alreadyQueried = mutableSetOf<HttpUrl>()
|
||||
|
||||
/**
|
||||
* Starting at current-user-principal URL, tries to recursively find and save all user relevant home sets.
|
||||
*
|
||||
*
|
||||
* @param principalUrl URL of principal to query (user-provided principal or current-user-principal)
|
||||
* @param level Current recursion level (limited to 0, 1 or 2):
|
||||
*
|
||||
* - 0: We assume found home sets belong to the current-user-principal
|
||||
* - 1 or 2: We assume found home sets don't directly belong to the current-user-principal
|
||||
*
|
||||
* @throws java.io.IOException
|
||||
* @throws HttpException
|
||||
* @throws at.bitfire.dav4jvm.exception.DavException
|
||||
*/
|
||||
internal fun discoverHomesets(principalUrl: HttpUrl, level: Int = 0) {
|
||||
Logger.log.fine("Discovering homesets of $principalUrl")
|
||||
val relatedResources = mutableSetOf<HttpUrl>()
|
||||
|
||||
// Define homeset class and properties to look for
|
||||
val homeSetClass: Class<out HrefListProperty>
|
||||
val properties: Array<Property.Name>
|
||||
when (service.type) {
|
||||
Service.TYPE_CARDDAV -> {
|
||||
homeSetClass = AddressbookHomeSet::class.java
|
||||
properties = arrayOf(DisplayName.NAME, AddressbookHomeSet.NAME, GroupMembership.NAME, ResourceType.NAME)
|
||||
}
|
||||
Service.TYPE_CALDAV -> {
|
||||
homeSetClass = CalendarHomeSet::class.java
|
||||
properties = arrayOf(DisplayName.NAME, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME, ResourceType.NAME)
|
||||
}
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
// Query the URL
|
||||
val principal = DavResource(httpClient, principalUrl)
|
||||
val personal = level == 0
|
||||
try {
|
||||
principal.propfind(0, *properties) { davResponse, _ ->
|
||||
alreadyQueried += davResponse.href
|
||||
|
||||
// If response holds home sets, save them
|
||||
davResponse[homeSetClass]?.let { homeSets ->
|
||||
for (homeSetHref in homeSets.hrefs)
|
||||
principal.location.resolve(homeSetHref)?.let { homesetUrl ->
|
||||
val resolvedHomeSetUrl = UrlUtils.withTrailingSlash(homesetUrl)
|
||||
// Homeset is considered personal if this is the outer recursion call,
|
||||
// This is because we assume the first call to query the current-user-principal
|
||||
// Note: This is not be be confused with the DAV:owner attribute. Home sets can be owned by
|
||||
// other principals and still be considered "personal" (belonging to the current-user-principal).
|
||||
db.homeSetDao().insertOrUpdateByUrl(
|
||||
HomeSet(0, service.id, personal, resolvedHomeSetUrl)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Add related principals to be queried afterwards
|
||||
if (personal) {
|
||||
val relatedResourcesTypes = listOf(
|
||||
// current resource is a read/write-proxy for other principals
|
||||
CalendarProxyReadFor::class.java,
|
||||
CalendarProxyWriteFor::class.java,
|
||||
// current resource is a member of a group (principal that can also have proxies)
|
||||
GroupMembership::class.java)
|
||||
for (type in relatedResourcesTypes)
|
||||
davResponse[type]?.let {
|
||||
for (href in it.hrefs)
|
||||
principal.location.resolve(href)?.let { url ->
|
||||
relatedResources += url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If current resource is a calendar-proxy-read/write, it's likely that its parent is a principal, too.
|
||||
davResponse[ResourceType::class.java]?.let { resourceType ->
|
||||
val proxyProperties = arrayOf(
|
||||
ResourceType.CALENDAR_PROXY_READ,
|
||||
ResourceType.CALENDAR_PROXY_WRITE,
|
||||
)
|
||||
if (proxyProperties.any { resourceType.types.contains(it) })
|
||||
relatedResources += davResponse.href.parent()
|
||||
}
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
if (e.code/100 == 4)
|
||||
Logger.log.log(Level.INFO, "Ignoring Client Error 4xx while looking for ${service.type} home sets", e)
|
||||
else
|
||||
throw e
|
||||
}
|
||||
|
||||
// query related resources
|
||||
if (level <= 1)
|
||||
for (resource in relatedResources)
|
||||
if (alreadyQueried.contains(resource))
|
||||
Logger.log.warning("$resource already queried, skipping")
|
||||
else
|
||||
discoverHomesets(resource, level + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes homesets and their collections.
|
||||
*
|
||||
* Each stored homeset URL is queried (propfind) and it's collections ([MultiResponseCallback]) either saved, updated
|
||||
* or marked as homeless - in case a collection was removed from its homeset.
|
||||
*
|
||||
* If a homeset URL in fact points to a collection directly, the collection will be saved with this URL,
|
||||
* and a null value for it's homeset. Refreshing of collections without homesets is then handled by [refreshHomelessCollections].
|
||||
*/
|
||||
internal fun refreshHomesetsAndTheirCollections() {
|
||||
val homesets = db.homeSetDao().getByService(service.id).associateBy { it.url }.toMutableMap()
|
||||
for((homeSetUrl, localHomeset) in homesets) {
|
||||
Logger.log.fine("Listing home set $homeSetUrl")
|
||||
|
||||
// To find removed collections in this homeset: create a queue from existing collections and remove every collection that
|
||||
// is successfully rediscovered. If there are collections left, after processing is done, these are marked homeless.
|
||||
val localHomesetCollections = db.collectionDao()
|
||||
.getByServiceAndHomeset(service.id, localHomeset.id)
|
||||
.associateBy { it.url }
|
||||
.toMutableMap()
|
||||
|
||||
try {
|
||||
DavResource(httpClient, homeSetUrl).propfind(1, *DAV_COLLECTION_PROPERTIES) { response, relation ->
|
||||
// Note: This callback may be called multiple times ([MultiResponseCallback])
|
||||
if (!response.isSuccess())
|
||||
return@propfind
|
||||
|
||||
if (relation == Response.HrefRelation.SELF) {
|
||||
// this response is about the homeset itself
|
||||
localHomeset.displayName = response[DisplayName::class.java]?.displayName
|
||||
localHomeset.privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind ?: true
|
||||
db.homeSetDao().insertOrUpdateByUrl(localHomeset)
|
||||
}
|
||||
|
||||
// in any case, check whether the response is about a usable collection
|
||||
val collection = Collection.fromDavResponse(response) ?: return@propfind
|
||||
|
||||
collection.serviceId = service.id
|
||||
collection.homeSetId = localHomeset.id
|
||||
collection.sync = shouldPreselect(collection, homesets.values)
|
||||
|
||||
// .. and save the principal url (collection owner)
|
||||
response[Owner::class.java]?.href
|
||||
?.let { response.href.resolve(it) }
|
||||
?.let { principalUrl ->
|
||||
val principal = Principal.fromServiceAndUrl(service, principalUrl)
|
||||
val id = db.principalDao().insertOrUpdate(service.id, principal)
|
||||
collection.ownerId = id
|
||||
}
|
||||
|
||||
Logger.log.log(Level.FINE, "Found collection", collection)
|
||||
|
||||
// save or update collection if usable (ignore it otherwise)
|
||||
if (isUsableCollection(collection))
|
||||
db.collectionDao().insertOrUpdateByUrlAndRememberFlags(collection)
|
||||
|
||||
// Remove this collection from queue - because it was found in the home set
|
||||
localHomesetCollections.remove(collection.url)
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
// delete home set locally if it was not accessible (40x)
|
||||
if (e.code in arrayOf(403, 404, 410))
|
||||
db.homeSetDao().delete(localHomeset)
|
||||
}
|
||||
|
||||
// Mark leftover (not rediscovered) collections from queue as homeless (remove association)
|
||||
for ((_, homelessCollection) in localHomesetCollections) {
|
||||
homelessCollection.homeSetId = null
|
||||
db.collectionDao().insertOrUpdateByUrlAndRememberFlags(homelessCollection)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes collections which don't have a homeset.
|
||||
*
|
||||
* It queries each stored collection with a homeSetId of "null" and either updates or deletes (if inaccessible or unusable) them.
|
||||
*/
|
||||
internal fun refreshHomelessCollections() {
|
||||
val homelessCollections = db.collectionDao().getByServiceAndHomeset(service.id, null).associateBy { it.url }.toMutableMap()
|
||||
for((url, localCollection) in homelessCollections) try {
|
||||
DavResource(httpClient, url).propfind(0, *DAV_COLLECTION_PROPERTIES) { response, _ ->
|
||||
if (!response.isSuccess()) {
|
||||
db.collectionDao().delete(localCollection)
|
||||
return@propfind
|
||||
}
|
||||
|
||||
// Save or update the collection, if usable, otherwise delete it
|
||||
Collection.fromDavResponse(response)?.let { collection ->
|
||||
if (!isUsableCollection(collection))
|
||||
return@let
|
||||
collection.serviceId = localCollection.serviceId // use same service ID as previous entry
|
||||
|
||||
// .. and save the principal url (collection owner)
|
||||
response[Owner::class.java]?.href
|
||||
?.let { response.href.resolve(it) }
|
||||
?.let { principalUrl ->
|
||||
val principal = Principal.fromServiceAndUrl(service, principalUrl)
|
||||
val principalId = db.principalDao().insertOrUpdate(service.id, principal)
|
||||
collection.ownerId = principalId
|
||||
}
|
||||
|
||||
db.collectionDao().insertOrUpdateByUrlAndRememberFlags(collection)
|
||||
} ?: db.collectionDao().delete(localCollection)
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
// delete collection locally if it was not accessible (40x)
|
||||
if (e.code in arrayOf(403, 404, 410))
|
||||
db.collectionDao().delete(localCollection)
|
||||
else
|
||||
throw e
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the principals (get their current display names).
|
||||
* Also removes principals which do not own any collections anymore.
|
||||
*/
|
||||
internal fun refreshPrincipals() {
|
||||
// Refresh principals (collection owner urls)
|
||||
val principals = db.principalDao().getByService(service.id)
|
||||
for (oldPrincipal in principals) {
|
||||
val principalUrl = oldPrincipal.url
|
||||
Logger.log.fine("Querying principal $principalUrl")
|
||||
try {
|
||||
DavResource(httpClient, principalUrl).propfind(0, *DAV_PRINCIPAL_PROPERTIES) { response, _ ->
|
||||
if (!response.isSuccess())
|
||||
return@propfind
|
||||
Principal.fromDavResponse(service.id, response)?.let { principal ->
|
||||
Logger.log.fine("Got principal: $principal")
|
||||
db.principalDao().insertOrUpdate(service.id, principal)
|
||||
}
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
Logger.log.info("Principal update failed with response code ${e.code}. principalUrl=$principalUrl")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete principals which don't own any collections
|
||||
db.principalDao().getAllWithoutCollections().forEach {principal ->
|
||||
db.principalDao().delete(principal)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds out whether given collection is usable, by checking that either
|
||||
* - CalDAV/CardDAV: service and collection type match, or
|
||||
* - WebCal: subscription source URL is not empty
|
||||
*/
|
||||
private fun isUsableCollection(collection: Collection) =
|
||||
(service.type == Service.TYPE_CARDDAV && collection.type == Collection.TYPE_ADDRESSBOOK) ||
|
||||
(service.type == Service.TYPE_CALDAV && arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(collection.type)) ||
|
||||
(collection.type == Collection.TYPE_WEBCAL && collection.source != null)
|
||||
|
||||
/**
|
||||
* Whether to preselect the given collection for synchronisation, according to the
|
||||
* settings [Settings.PRESELECT_COLLECTIONS] (see there for allowed values) and
|
||||
* [Settings.PRESELECT_COLLECTIONS_EXCLUDED].
|
||||
*
|
||||
* A collection is considered _personal_ if it is found in one of the current-user-principal's home-sets.
|
||||
*
|
||||
* Before a collection is pre-selected, we check whether its URL matches the regexp in
|
||||
* [Settings.PRESELECT_COLLECTIONS_EXCLUDED], in which case *false* is returned.
|
||||
*
|
||||
* @param collection the collection to check
|
||||
* @param homesets list of home-sets (to check whether collection is in a personal home-set)
|
||||
* @return *true* if the collection should be preselected for synchronization; *false* otherwise
|
||||
*/
|
||||
internal fun shouldPreselect(collection: Collection, homesets: Iterable<HomeSet>): Boolean {
|
||||
val shouldPreselect = settings.getIntOrNull(Settings.PRESELECT_COLLECTIONS)
|
||||
|
||||
val excluded by lazy {
|
||||
val excludedRegex = settings.getString(Settings.PRESELECT_COLLECTIONS_EXCLUDED)
|
||||
if (!excludedRegex.isNullOrEmpty())
|
||||
Regex(excludedRegex).containsMatchIn(collection.url.toString())
|
||||
else
|
||||
false
|
||||
}
|
||||
|
||||
return when (shouldPreselect) {
|
||||
Settings.PRESELECT_COLLECTIONS_ALL ->
|
||||
// preselect if collection url is not excluded
|
||||
!excluded
|
||||
|
||||
Settings.PRESELECT_COLLECTIONS_PERSONAL ->
|
||||
// preselect if is personal (in a personal home-set), but not excluded
|
||||
homesets
|
||||
.filter { homeset -> homeset.personal }
|
||||
.map { homeset -> homeset.id }
|
||||
.contains(collection.homeSetId)
|
||||
&& !excluded
|
||||
|
||||
else -> // don't preselect
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,517 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
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 androidx.annotation.WorkerThread
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.syncadapter.PeriodicSyncWorker
|
||||
import at.bitfire.davdroid.syncadapter.SyncUtils
|
||||
import at.bitfire.davdroid.util.setAndVerifyUserData
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import net.openid.appauth.AuthState
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Manages settings of an account.
|
||||
*
|
||||
* @param context Required to access account settings
|
||||
* @param argAccount Account to take settings from. If this account is an address book account,
|
||||
* settings will be taken from the corresponding main account instead.
|
||||
*
|
||||
* @throws InvalidAccountException on construction when the account doesn't exist (anymore)
|
||||
* @throws IllegalArgumentException when the account type is not _DAVx5_ or _DAVx5 address book_
|
||||
*/
|
||||
class AccountSettings(
|
||||
val context: Context,
|
||||
argAccount: Account
|
||||
) {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface AccountSettingsEntryPoint {
|
||||
fun settingsManager(): SettingsManager
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val CURRENT_VERSION = 15
|
||||
const val KEY_SETTINGS_VERSION = "version"
|
||||
|
||||
const val KEY_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks"
|
||||
const val KEY_SYNC_INTERVAL_CALENDARS = "sync_interval_calendars"
|
||||
|
||||
/** Stores the tasks sync interval (in seconds) so that it can be set again when the provider is switched */
|
||||
const val KEY_SYNC_INTERVAL_TASKS = "sync_interval_tasks"
|
||||
|
||||
const val KEY_USERNAME = "user_name"
|
||||
const val KEY_CERTIFICATE_ALIAS = "certificate_alias"
|
||||
|
||||
/** OAuth [AuthState] (serialized as JSON) */
|
||||
const val KEY_AUTH_STATE = "auth_state"
|
||||
|
||||
const val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false)
|
||||
const val KEY_WIFI_ONLY_SSIDS = "wifi_only_ssids" // restrict sync to specific WiFi SSIDs
|
||||
const val KEY_IGNORE_VPNS = "ignore_vpns" // ignore vpns at connection detection
|
||||
|
||||
/** Time range limitation to the past [in days]. Values:
|
||||
*
|
||||
* - null: default value (DEFAULT_TIME_RANGE_PAST_DAYS)
|
||||
* - <0 (typically -1): no limit
|
||||
* - n>0: entries more than n days in the past won't be synchronized
|
||||
*/
|
||||
const val KEY_TIME_RANGE_PAST_DAYS = "time_range_past_days"
|
||||
const val DEFAULT_TIME_RANGE_PAST_DAYS = 90
|
||||
|
||||
/**
|
||||
* Whether a default alarm shall be assigned to received events/tasks which don't have an alarm.
|
||||
* Value can be null (no default alarm) or an integer (default alarm shall be created this
|
||||
* number of minutes before the event/task).
|
||||
*/
|
||||
const val KEY_DEFAULT_ALARM = "default_alarm"
|
||||
|
||||
/** Whether DAVx5 sets the local calendar color to the value from service DB at every sync
|
||||
value = *null* (not existing): true (default);
|
||||
"0" false */
|
||||
const val KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors"
|
||||
|
||||
/** Whether DAVx5 populates and uses CalendarContract.Colors
|
||||
value = *null* (not existing) false (default);
|
||||
"1" true */
|
||||
const val KEY_EVENT_COLORS = "event_colors"
|
||||
|
||||
/** Contact group method:
|
||||
*null (not existing)* groups as separate vCards (default);
|
||||
"CATEGORIES" groups are per-contact CATEGORIES
|
||||
*/
|
||||
const val KEY_CONTACT_GROUP_METHOD = "contact_group_method"
|
||||
|
||||
/** UI preference: Show only personal collections
|
||||
value = *null* (not existing) show all collections (default);
|
||||
"1" show only personal collections */
|
||||
const val KEY_SHOW_ONLY_PERSONAL = "show_only_personal"
|
||||
|
||||
const val SYNC_INTERVAL_MANUALLY = -1L
|
||||
|
||||
/** Static property to indicate whether AccountSettings migration is currently running.
|
||||
* **Access must be `synchronized` with `AccountSettings::class.java`.** */
|
||||
@Volatile
|
||||
var currentlyUpdating = false
|
||||
|
||||
fun initialUserData(credentials: Credentials?): Bundle {
|
||||
val bundle = Bundle()
|
||||
bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
|
||||
|
||||
if (credentials != null) {
|
||||
if (credentials.username != null)
|
||||
bundle.putString(KEY_USERNAME, credentials.username)
|
||||
|
||||
if (credentials.certificateAlias != null)
|
||||
bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
|
||||
|
||||
if (credentials.authState != null)
|
||||
bundle.putString(KEY_AUTH_STATE, credentials.authState.jsonSerializeString())
|
||||
}
|
||||
|
||||
return bundle
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
val settings = EntryPointAccessors.fromApplication(context, AccountSettingsEntryPoint::class.java).settingsManager()
|
||||
|
||||
val accountManager: AccountManager = AccountManager.get(context)
|
||||
val account: Account
|
||||
|
||||
init {
|
||||
account = when (argAccount.type) {
|
||||
context.getString(R.string.account_type_address_book) -> {
|
||||
/* argAccount is an address book account, which is not a main account. However settings are
|
||||
stored in the main account, so resolve and use the main account instead. */
|
||||
LocalAddressBook.mainAccount(context, argAccount) ?: throw IllegalArgumentException("Main account of $argAccount not found")
|
||||
}
|
||||
|
||||
context.getString(R.string.account_type) ->
|
||||
argAccount
|
||||
|
||||
else ->
|
||||
throw IllegalArgumentException("Account type ${argAccount.type} not supported")
|
||||
}
|
||||
|
||||
// synchronize because account migration must only be run one time
|
||||
synchronized(AccountSettings::class.java) {
|
||||
val versionStr = accountManager.getUserData(account, KEY_SETTINGS_VERSION) ?: throw InvalidAccountException(account)
|
||||
var version = 0
|
||||
try {
|
||||
version = Integer.parseInt(versionStr)
|
||||
} catch (e: NumberFormatException) {
|
||||
Logger.log.log(Level.SEVERE, "Invalid account version: $versionStr", e)
|
||||
}
|
||||
Logger.log.fine("Account ${account.name} has version $version, current version: $CURRENT_VERSION")
|
||||
|
||||
if (version < CURRENT_VERSION) {
|
||||
if (currentlyUpdating) {
|
||||
Logger.log.severe("Redundant call: migration created AccountSettings(). This must never happen.")
|
||||
throw IllegalStateException("Redundant call: migration created AccountSettings()")
|
||||
} else {
|
||||
currentlyUpdating = true
|
||||
update(version)
|
||||
currentlyUpdating = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// authentication settings
|
||||
|
||||
fun credentials() = Credentials(
|
||||
accountManager.getUserData(account, KEY_USERNAME),
|
||||
accountManager.getPassword(account),
|
||||
|
||||
accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS),
|
||||
|
||||
accountManager.getUserData(account, KEY_AUTH_STATE)?.let { json ->
|
||||
AuthState.jsonDeserialize(json)
|
||||
}
|
||||
)
|
||||
|
||||
fun credentials(credentials: Credentials) {
|
||||
// Basic/Digest auth
|
||||
accountManager.setAndVerifyUserData(account, KEY_USERNAME, credentials.username)
|
||||
accountManager.setPassword(account, credentials.password)
|
||||
|
||||
// client certificate
|
||||
accountManager.setAndVerifyUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
|
||||
|
||||
// OAuth
|
||||
accountManager.setAndVerifyUserData(account, KEY_AUTH_STATE, credentials.authState?.jsonSerializeString())
|
||||
}
|
||||
|
||||
|
||||
// sync. settings
|
||||
|
||||
/**
|
||||
* Gets the currently set sync interval for this account in seconds.
|
||||
*
|
||||
* @param authority authority to check (for instance: [CalendarContract.AUTHORITY]])
|
||||
* @return sync interval in seconds; *[SYNC_INTERVAL_MANUALLY]* if manual sync; *null* if not set
|
||||
*/
|
||||
fun getSyncInterval(authority: String): Long? {
|
||||
if (ContentResolver.getIsSyncable(account, authority) <= 0)
|
||||
return null
|
||||
|
||||
val key = when {
|
||||
authority == context.getString(R.string.address_books_authority) ->
|
||||
KEY_SYNC_INTERVAL_ADDRESSBOOKS
|
||||
authority == CalendarContract.AUTHORITY ->
|
||||
KEY_SYNC_INTERVAL_CALENDARS
|
||||
TaskProvider.ProviderName.entries.any { it.authority == authority } ->
|
||||
KEY_SYNC_INTERVAL_TASKS
|
||||
else -> return null
|
||||
}
|
||||
return accountManager.getUserData(account, key)?.toLong()
|
||||
}
|
||||
|
||||
fun getTasksSyncInterval() = accountManager.getUserData(account, KEY_SYNC_INTERVAL_TASKS)?.toLong()
|
||||
|
||||
/**
|
||||
* Sets the sync interval and en- or disables periodic sync for the given account and authority.
|
||||
* Does *not* call [ContentResolver.setIsSyncable].
|
||||
*
|
||||
* This method blocks until a worker as been created and enqueued (sync active) or removed
|
||||
* (sync disabled), so it should not be called from the UI thread.
|
||||
*
|
||||
* @param authority sync authority (like [CalendarContract.AUTHORITY])
|
||||
* @param _seconds if [SYNC_INTERVAL_MANUALLY]: automatic sync will be disabled;
|
||||
* otherwise (must be ≥ 15 min): automatic sync will be enabled and set to the given number of seconds
|
||||
*/
|
||||
@WorkerThread
|
||||
fun setSyncInterval(authority: String, _seconds: Long) {
|
||||
val seconds =
|
||||
if (_seconds != SYNC_INTERVAL_MANUALLY && _seconds < 60*15)
|
||||
60*15
|
||||
else
|
||||
_seconds
|
||||
|
||||
// Store (user defined) sync interval in account settings
|
||||
val key = when {
|
||||
authority == context.getString(R.string.address_books_authority) ->
|
||||
KEY_SYNC_INTERVAL_ADDRESSBOOKS
|
||||
authority == CalendarContract.AUTHORITY ->
|
||||
KEY_SYNC_INTERVAL_CALENDARS
|
||||
TaskProvider.ProviderName.entries.any { it.authority == authority } ->
|
||||
KEY_SYNC_INTERVAL_TASKS
|
||||
else -> {
|
||||
Logger.log.warning("Sync interval not applicable to authority $authority")
|
||||
return
|
||||
}
|
||||
}
|
||||
accountManager.setAndVerifyUserData(account, key, seconds.toString())
|
||||
|
||||
// update sync workers (needs already updated sync interval in AccountSettings)
|
||||
updatePeriodicSyncWorker(authority, seconds, getSyncWifiOnly())
|
||||
|
||||
// Also enable/disable content change triggered syncs (SyncFramework automatic sync).
|
||||
// We could make this a separate user adjustable setting later on.
|
||||
setSyncOnContentChange(authority, seconds != SYNC_INTERVAL_MANUALLY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables/disables sync adapter automatic sync (content triggered sync) for the given
|
||||
* account and authority. Does *not* call [ContentResolver.setIsSyncable].
|
||||
*
|
||||
* We use the sync adapter framework only for the trigger, actual syncing is implemented
|
||||
* with WorkManager. The trigger comes in through SyncAdapterService.
|
||||
*
|
||||
* This method blocks until the sync-on-content-change has been enabled or disabled, so it
|
||||
* should not be called from the UI thread.
|
||||
*
|
||||
* @param enable *true* enables automatic sync; *false* disables it
|
||||
* @param authority sync authority (like [CalendarContract.AUTHORITY])
|
||||
* @return whether the content triggered sync was enabled successfully
|
||||
*/
|
||||
@WorkerThread
|
||||
fun setSyncOnContentChange(authority: String, enable: Boolean): Boolean {
|
||||
// Enable content change triggers (sync adapter framework)
|
||||
val setContentTrigger: () -> Boolean =
|
||||
/* Ugly hack: because there is no callback for when the sync status/interval has been
|
||||
updated, we need to make this call blocking. */
|
||||
if (enable) {{
|
||||
Logger.log.fine("Enabling content-triggered sync of $account/$authority")
|
||||
ContentResolver.setSyncAutomatically(account, authority, true) // enables content triggers
|
||||
// Remove unwanted sync framework periodic syncs created by setSyncAutomatically
|
||||
for (periodicSync in ContentResolver.getPeriodicSyncs(account, authority))
|
||||
ContentResolver.removePeriodicSync(periodicSync.account, periodicSync.authority, periodicSync.extras)
|
||||
/* return */ ContentResolver.getSyncAutomatically(account, authority)
|
||||
}} else {{
|
||||
Logger.log.fine("Disabling content-triggered sync of $account/$authority")
|
||||
ContentResolver.setSyncAutomatically(account, authority, false) // disables content triggers
|
||||
/* return */ !ContentResolver.getSyncAutomatically(account, authority)
|
||||
}}
|
||||
|
||||
// try up to 10 times with 100 ms pause
|
||||
for (idxTry in 0 until 10) {
|
||||
if (setContentTrigger())
|
||||
// successfully set
|
||||
return true
|
||||
Thread.sleep(100)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun getSyncWifiOnly() =
|
||||
if (settings.containsKey(KEY_WIFI_ONLY))
|
||||
settings.getBoolean(KEY_WIFI_ONLY)
|
||||
else
|
||||
accountManager.getUserData(account, KEY_WIFI_ONLY) != null
|
||||
|
||||
fun setSyncWiFiOnly(wiFiOnly: Boolean) {
|
||||
accountManager.setAndVerifyUserData(account, KEY_WIFI_ONLY, if (wiFiOnly) "1" else null)
|
||||
|
||||
// update sync workers (needs already updated wifi-only flag in AccountSettings)
|
||||
for (authority in SyncUtils.syncAuthorities(context))
|
||||
updatePeriodicSyncWorker(authority, getSyncInterval(authority), wiFiOnly)
|
||||
}
|
||||
|
||||
fun getSyncWifiOnlySSIDs(): List<String>? =
|
||||
if (getSyncWifiOnly()) {
|
||||
val strSsids = if (settings.containsKey(KEY_WIFI_ONLY_SSIDS))
|
||||
settings.getString(KEY_WIFI_ONLY_SSIDS)
|
||||
else
|
||||
accountManager.getUserData(account, KEY_WIFI_ONLY_SSIDS)
|
||||
strSsids?.split(',')
|
||||
} else
|
||||
null
|
||||
fun setSyncWifiOnlySSIDs(ssids: List<String>?) =
|
||||
accountManager.setAndVerifyUserData(account, KEY_WIFI_ONLY_SSIDS, StringUtils.trimToNull(ssids?.joinToString(",")))
|
||||
|
||||
fun getIgnoreVpns(): Boolean =
|
||||
when (accountManager.getUserData(account, KEY_IGNORE_VPNS)) {
|
||||
null -> settings.getBoolean(KEY_IGNORE_VPNS)
|
||||
"0" -> false
|
||||
else -> true
|
||||
}
|
||||
|
||||
fun setIgnoreVpns(ignoreVpns: Boolean) =
|
||||
accountManager.setAndVerifyUserData(account, KEY_IGNORE_VPNS, if (ignoreVpns) "1" else "0")
|
||||
|
||||
/**
|
||||
* Updates the periodic sync worker of an authority according to
|
||||
*
|
||||
* - the sync interval and
|
||||
* - the _Sync WiFi only_ flag.
|
||||
*
|
||||
* @param authority periodic sync workers for this authority will be updated
|
||||
* @param seconds sync interval in seconds (`null` or [SYNC_INTERVAL_MANUALLY] disables periodic sync)
|
||||
* @param wiFiOnly sync Wifi only flag
|
||||
*/
|
||||
fun updatePeriodicSyncWorker(authority: String, seconds: Long?, wiFiOnly: Boolean) {
|
||||
try {
|
||||
if (seconds == null || seconds == SYNC_INTERVAL_MANUALLY) {
|
||||
Logger.log.fine("Disabling periodic sync of $account/$authority")
|
||||
PeriodicSyncWorker.disable(context, account, authority)
|
||||
} else {
|
||||
Logger.log.fine("Setting periodic sync of $account/$authority to $seconds seconds (wifiOnly=$wiFiOnly)")
|
||||
PeriodicSyncWorker.enable(context, account, authority, seconds, wiFiOnly)
|
||||
}.result.get() // On operation (enable/disable) failure exception is thrown
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Failed to set sync interval of $account/$authority to $seconds seconds", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// CalDAV settings
|
||||
|
||||
fun getTimeRangePastDays(): Int? {
|
||||
val strDays = accountManager.getUserData(account, KEY_TIME_RANGE_PAST_DAYS)
|
||||
return if (strDays != null) {
|
||||
val days = strDays.toInt()
|
||||
if (days < 0)
|
||||
null
|
||||
else
|
||||
days
|
||||
} else
|
||||
DEFAULT_TIME_RANGE_PAST_DAYS
|
||||
}
|
||||
|
||||
fun setTimeRangePastDays(days: Int?) =
|
||||
accountManager.setAndVerifyUserData(account, KEY_TIME_RANGE_PAST_DAYS, (days ?: -1).toString())
|
||||
|
||||
/**
|
||||
* Takes the default alarm setting (in this order) from
|
||||
*
|
||||
* 1. the local account settings
|
||||
* 2. the settings provider (unless the value is -1 there).
|
||||
*
|
||||
* @return A default reminder shall be created this number of minutes before the start of every
|
||||
* non-full-day event without reminder. *null*: No default reminders shall be created.
|
||||
*/
|
||||
fun getDefaultAlarm() =
|
||||
accountManager.getUserData(account, KEY_DEFAULT_ALARM)?.toInt() ?:
|
||||
settings.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 }
|
||||
|
||||
/**
|
||||
* Sets the default alarm value in the local account settings, if the new value differs
|
||||
* from the value of the settings provider. If the new value is the same as the value of
|
||||
* the settings provider, the local setting will be deleted, so that the settings provider
|
||||
* value applies.
|
||||
*
|
||||
* @param minBefore The number of minutes a default reminder shall be created before the
|
||||
* start of every non-full-day event without reminder. *null*: No default reminders shall be created.
|
||||
*/
|
||||
fun setDefaultAlarm(minBefore: Int?) =
|
||||
accountManager.setAndVerifyUserData(account, KEY_DEFAULT_ALARM,
|
||||
if (minBefore == settings.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 })
|
||||
null
|
||||
else
|
||||
minBefore?.toString())
|
||||
|
||||
fun getManageCalendarColors() = if (settings.containsKey(KEY_MANAGE_CALENDAR_COLORS))
|
||||
settings.getBoolean(KEY_MANAGE_CALENDAR_COLORS)
|
||||
else
|
||||
accountManager.getUserData(account, KEY_MANAGE_CALENDAR_COLORS) == null
|
||||
fun setManageCalendarColors(manage: Boolean) =
|
||||
accountManager.setAndVerifyUserData(account, KEY_MANAGE_CALENDAR_COLORS, if (manage) null else "0")
|
||||
|
||||
fun getEventColors() = if (settings.containsKey(KEY_EVENT_COLORS))
|
||||
settings.getBoolean(KEY_EVENT_COLORS)
|
||||
else
|
||||
accountManager.getUserData(account, KEY_EVENT_COLORS) != null
|
||||
fun setEventColors(useColors: Boolean) =
|
||||
accountManager.setAndVerifyUserData(account, KEY_EVENT_COLORS, if (useColors) "1" else null)
|
||||
|
||||
// CardDAV settings
|
||||
|
||||
fun getGroupMethod(): GroupMethod {
|
||||
val name = settings.getString(KEY_CONTACT_GROUP_METHOD) ?:
|
||||
accountManager.getUserData(account, KEY_CONTACT_GROUP_METHOD)
|
||||
if (name != null)
|
||||
try {
|
||||
return GroupMethod.valueOf(name)
|
||||
}
|
||||
catch (_: IllegalArgumentException) {
|
||||
}
|
||||
return GroupMethod.GROUP_VCARDS
|
||||
}
|
||||
|
||||
fun setGroupMethod(method: GroupMethod) {
|
||||
accountManager.setAndVerifyUserData(account, KEY_CONTACT_GROUP_METHOD, method.name)
|
||||
}
|
||||
|
||||
|
||||
// UI settings
|
||||
|
||||
data class ShowOnlyPersonal(
|
||||
val onlyPersonal: Boolean,
|
||||
val locked: Boolean
|
||||
)
|
||||
|
||||
fun getShowOnlyPersonal(): ShowOnlyPersonal {
|
||||
@Suppress("DEPRECATION")
|
||||
val pair = getShowOnlyPersonalPair()
|
||||
return ShowOnlyPersonal(onlyPersonal = pair.first, locked = !pair.second)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether only personal collections should be shown.
|
||||
*
|
||||
* @return [Pair] of values:
|
||||
*
|
||||
* 1. (first) whether only personal collections should be shown
|
||||
* 2. (second) whether the user shall be able to change the setting (= setting not locked)
|
||||
*/
|
||||
@Deprecated("Use getShowOnlyPersonal() instead", replaceWith = ReplaceWith("getShowOnlyPersonal()"))
|
||||
fun getShowOnlyPersonalPair(): Pair<Boolean, Boolean> =
|
||||
when (settings.getIntOrNull(KEY_SHOW_ONLY_PERSONAL)) {
|
||||
0 -> Pair(false, false)
|
||||
1 -> Pair(true, false)
|
||||
else /* including -1 */ -> Pair(accountManager.getUserData(account, KEY_SHOW_ONLY_PERSONAL) != null, true)
|
||||
}
|
||||
|
||||
fun setShowOnlyPersonal(showOnlyPersonal: Boolean) {
|
||||
accountManager.setAndVerifyUserData(account, KEY_SHOW_ONLY_PERSONAL, if (showOnlyPersonal) "1" else null)
|
||||
}
|
||||
|
||||
|
||||
// update from previous account settings
|
||||
|
||||
private fun update(baseVersion: Int) {
|
||||
for (toVersion in baseVersion+1 ..CURRENT_VERSION) {
|
||||
val fromVersion = toVersion-1
|
||||
Logger.log.info("Updating account ${account.name} from version $fromVersion to $toVersion")
|
||||
try {
|
||||
val migrations = AccountSettingsMigrations(
|
||||
context = context,
|
||||
account = account,
|
||||
accountSettings = this
|
||||
)
|
||||
val updateProc = AccountSettingsMigrations::class.java.getDeclaredMethod("update_${fromVersion}_$toVersion")
|
||||
updateProc.invoke(migrations)
|
||||
|
||||
Logger.log.info("Account version update successful")
|
||||
accountManager.setAndVerifyUserData(account, KEY_SETTINGS_VERSION, toVersion.toString())
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't update account settings", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,434 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Parcel
|
||||
import android.os.RemoteException
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.util.Base64
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalTask
|
||||
import at.bitfire.davdroid.syncadapter.BaseSyncWorker
|
||||
import at.bitfire.davdroid.syncadapter.SyncUtils
|
||||
import at.bitfire.davdroid.util.TaskUtils
|
||||
import at.bitfire.davdroid.util.setAndVerifyUserData
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.AndroidEvent
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.ical4android.UnknownProperty
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import at.techbee.jtx.JtxContract.asSyncAdapter
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import net.fortuna.ical4j.model.Property
|
||||
import net.fortuna.ical4j.model.property.Url
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.dmfs.tasks.contract.TaskContract
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ObjectInputStream
|
||||
import java.util.logging.Level
|
||||
|
||||
class AccountSettingsMigrations(
|
||||
val context: Context,
|
||||
val account: Account,
|
||||
val accountSettings: AccountSettings
|
||||
) {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface AccountSettingsMigrationsEntryPoint {
|
||||
fun appDatabase(): AppDatabase
|
||||
fun settingsManager(): SettingsManager
|
||||
}
|
||||
|
||||
val db = EntryPointAccessors.fromApplication<AccountSettingsMigrationsEntryPoint>(context).appDatabase()
|
||||
val settings = EntryPointAccessors.fromApplication<AccountSettingsMigrationsEntryPoint>(context).settingsManager()
|
||||
|
||||
val accountManager: AccountManager = AccountManager.get(context)
|
||||
|
||||
|
||||
/**
|
||||
* Updates the periodic sync workers by re-setting the same sync interval.
|
||||
*
|
||||
* The goal is to add the [BaseSyncWorker.commonTag] to all existing periodic sync workers so that they can be detected by
|
||||
* the new [BaseSyncWorker.exists] and [at.bitfire.davdroid.ui.AccountsActivity.Model].
|
||||
*/
|
||||
@Suppress("unused","FunctionName")
|
||||
fun update_14_15() {
|
||||
for (authority in SyncUtils.syncAuthorities(context)) {
|
||||
val interval = accountSettings.getSyncInterval(authority)
|
||||
accountSettings.setSyncInterval(authority, interval ?: AccountSettings.SYNC_INTERVAL_MANUALLY)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables all sync adapter periodic syncs for every authority. Then enables
|
||||
* corresponding PeriodicSyncWorkers
|
||||
*/
|
||||
@Suppress("unused","FunctionName")
|
||||
fun update_13_14() {
|
||||
// Cancel any potentially running syncs for this account (sync framework)
|
||||
ContentResolver.cancelSync(account, null)
|
||||
|
||||
val authorities = listOf(
|
||||
context.getString(R.string.address_books_authority),
|
||||
CalendarContract.AUTHORITY,
|
||||
TaskProvider.ProviderName.JtxBoard.authority,
|
||||
TaskProvider.ProviderName.OpenTasks.authority,
|
||||
TaskProvider.ProviderName.TasksOrg.authority
|
||||
)
|
||||
|
||||
for (authority in authorities) {
|
||||
// Enable PeriodicSyncWorker (WorkManager), with known intervals
|
||||
v14_enableWorkManager(authority)
|
||||
// Disable periodic syncs (sync adapter framework)
|
||||
v14_disableSyncFramework(authority)
|
||||
}
|
||||
}
|
||||
private fun v14_enableWorkManager(authority: String) {
|
||||
val enabled = accountSettings.getSyncInterval(authority)?.let { syncInterval ->
|
||||
accountSettings.setSyncInterval(authority, syncInterval)
|
||||
} ?: false
|
||||
Logger.log.info("PeriodicSyncWorker for $account/$authority enabled=$enabled")
|
||||
}
|
||||
private fun v14_disableSyncFramework(authority: String) {
|
||||
// Disable periodic syncs (sync adapter framework)
|
||||
val disable: () -> Boolean = {
|
||||
/* Ugly hack: because there is no callback for when the sync status/interval has been
|
||||
updated, we need to make this call blocking. */
|
||||
for (sync in ContentResolver.getPeriodicSyncs(account, authority))
|
||||
ContentResolver.removePeriodicSync(sync.account, sync.authority, sync.extras)
|
||||
|
||||
// check whether syncs are really disabled
|
||||
var result = true
|
||||
for (sync in ContentResolver.getPeriodicSyncs(account, authority)) {
|
||||
Logger.log.info("Sync framework still has a periodic sync for $account/$authority: $sync")
|
||||
result = false
|
||||
}
|
||||
result
|
||||
}
|
||||
// try up to 10 times with 100 ms pause
|
||||
var success = false
|
||||
for (idxTry in 0 until 10) {
|
||||
success = disable()
|
||||
if (success)
|
||||
break
|
||||
Thread.sleep(200)
|
||||
}
|
||||
Logger.log.info("Sync framework periodic syncs for $account/$authority disabled=$success")
|
||||
}
|
||||
|
||||
@Suppress("unused","FunctionName")
|
||||
/**
|
||||
* Not a per-account migration, but not a database migration, too, so it fits best there.
|
||||
* Best future solution would be that SettingsManager manages versions and migrations.
|
||||
*
|
||||
* Updates proxy settings from override_proxy_* to proxy_type, proxy_host, proxy_port.
|
||||
*/
|
||||
private fun update_12_13() {
|
||||
// proxy settings are managed by SharedPreferencesProvider
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
|
||||
// old setting names
|
||||
val overrideProxy = "override_proxy"
|
||||
val overrideProxyHost = "override_proxy_host"
|
||||
val overrideProxyPort = "override_proxy_port"
|
||||
|
||||
val edit = preferences.edit()
|
||||
if (preferences.contains(overrideProxy)) {
|
||||
if (preferences.getBoolean(overrideProxy, false))
|
||||
// override_proxy set, migrate to proxy_type = HTTP
|
||||
edit.putInt(Settings.PROXY_TYPE, Settings.PROXY_TYPE_HTTP)
|
||||
edit.remove(overrideProxy)
|
||||
}
|
||||
if (preferences.contains(overrideProxyHost)) {
|
||||
preferences.getString(overrideProxyHost, null)?.let { host ->
|
||||
edit.putString(Settings.PROXY_HOST, host)
|
||||
}
|
||||
edit.remove(overrideProxyHost)
|
||||
}
|
||||
if (preferences.contains(overrideProxyPort)) {
|
||||
val port = preferences.getInt(overrideProxyPort, 0)
|
||||
if (port != 0)
|
||||
edit.putInt(Settings.PROXY_PORT, port)
|
||||
edit.remove(overrideProxyPort)
|
||||
}
|
||||
edit.apply()
|
||||
}
|
||||
|
||||
|
||||
@Suppress("unused","FunctionName")
|
||||
/**
|
||||
* Store event URLs as URL (extended property) instead of unknown property. At the same time,
|
||||
* convert legacy unknown properties to the current format.
|
||||
*/
|
||||
private fun update_11_12() {
|
||||
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED) {
|
||||
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { provider ->
|
||||
// Attention: CalendarProvider does NOT limit the results of the ExtendedProperties query
|
||||
// to the given account! So all extended properties will be processed number-of-accounts times.
|
||||
val extUri = CalendarContract.ExtendedProperties.CONTENT_URI.asSyncAdapter(account)
|
||||
|
||||
provider.query(
|
||||
extUri, arrayOf(
|
||||
CalendarContract.ExtendedProperties._ID, // idx 0
|
||||
CalendarContract.ExtendedProperties.NAME, // idx 1
|
||||
CalendarContract.ExtendedProperties.VALUE // idx 2
|
||||
), null, null, null
|
||||
)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(0)
|
||||
val rawValue = cursor.getString(2)
|
||||
|
||||
val uri by lazy {
|
||||
ContentUris.withAppendedId(CalendarContract.ExtendedProperties.CONTENT_URI, id).asSyncAdapter(account)
|
||||
}
|
||||
|
||||
when (cursor.getString(1)) {
|
||||
UnknownProperty.CONTENT_ITEM_TYPE -> {
|
||||
// unknown property; check whether it's a URL
|
||||
try {
|
||||
val property = UnknownProperty.fromJsonString(rawValue)
|
||||
if (property is Url) { // rewrite to MIMETYPE_URL
|
||||
val newValues = ContentValues(2)
|
||||
newValues.put(CalendarContract.ExtendedProperties.NAME, AndroidEvent.EXTNAME_URL)
|
||||
newValues.put(CalendarContract.ExtendedProperties.VALUE, property.value)
|
||||
provider.update(uri, newValues, null, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(
|
||||
Level.WARNING,
|
||||
"Couldn't rewrite URL from unknown property to ${AndroidEvent.EXTNAME_URL}",
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"unknown-property" -> {
|
||||
// unknown property (deprecated format); convert to current format
|
||||
try {
|
||||
val stream = ByteArrayInputStream(Base64.decode(rawValue, Base64.NO_WRAP))
|
||||
ObjectInputStream(stream).use {
|
||||
(it.readObject() as? Property)?.let { property ->
|
||||
// rewrite to current format
|
||||
val newValues = ContentValues(2)
|
||||
newValues.put(CalendarContract.ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE)
|
||||
newValues.put(CalendarContract.ExtendedProperties.VALUE, UnknownProperty.toJsonString(property))
|
||||
provider.update(uri, newValues, null, null)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't rewrite deprecated unknown property to current format", e)
|
||||
}
|
||||
}
|
||||
|
||||
"unknown-property.v2" -> {
|
||||
// unknown property (deprecated MIME type); rewrite to current MIME type
|
||||
val newValues = ContentValues(1)
|
||||
newValues.put(CalendarContract.ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE)
|
||||
provider.update(uri, newValues, null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused","FunctionName")
|
||||
/**
|
||||
* The tasks sync interval should be stored in account settings. It's used to set the sync interval
|
||||
* again when the tasks provider is switched.
|
||||
*/
|
||||
private fun update_10_11() {
|
||||
TaskUtils.currentProvider(context)?.let { provider ->
|
||||
val interval = accountSettings.getSyncInterval(provider.authority)
|
||||
if (interval != null)
|
||||
accountManager.setAndVerifyUserData(account,
|
||||
AccountSettings.KEY_SYNC_INTERVAL_TASKS, interval.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused","FunctionName")
|
||||
/**
|
||||
* Task synchronization now handles alarms, categories, relations and unknown properties.
|
||||
* Setting task ETags to null will cause them to be downloaded (and parsed) again.
|
||||
*
|
||||
* Also update the allowed reminder types for calendars.
|
||||
**/
|
||||
private fun update_9_10() {
|
||||
TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use { provider ->
|
||||
val tasksUri = provider.tasksUri().asSyncAdapter(account)
|
||||
val emptyETag = ContentValues(1)
|
||||
emptyETag.putNull(LocalTask.COLUMN_ETAG)
|
||||
provider.client.update(tasksUri, emptyETag, "${TaskContract.Tasks._DIRTY}=0 AND ${TaskContract.Tasks._DELETED}=0", null)
|
||||
}
|
||||
|
||||
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED)
|
||||
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { provider ->
|
||||
provider.update(
|
||||
CalendarContract.Calendars.CONTENT_URI.asSyncAdapter(account),
|
||||
AndroidCalendar.calendarBaseValues, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused","FunctionName")
|
||||
/**
|
||||
* It seems that somehow some non-CalDAV accounts got OpenTasks syncable, which caused battery problems.
|
||||
* Disable it on those accounts for the future.
|
||||
*/
|
||||
private fun update_8_9() {
|
||||
val hasCalDAV = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) != null
|
||||
if (!hasCalDAV && ContentResolver.getIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority) != 0) {
|
||||
Logger.log.info("Disabling OpenTasks sync for $account")
|
||||
ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused","FunctionName")
|
||||
@SuppressLint("Recycle")
|
||||
/**
|
||||
* There is a mistake in this method. [TaskContract.Tasks.SYNC_VERSION] is used to store the
|
||||
* SEQUENCE and should not be used for the eTag.
|
||||
*/
|
||||
private fun update_7_8() {
|
||||
TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use { provider ->
|
||||
// ETag is now in sync_version instead of sync1
|
||||
// UID is now in _uid instead of sync2
|
||||
provider.client.query(provider.tasksUri().asSyncAdapter(account),
|
||||
arrayOf(TaskContract.Tasks._ID, TaskContract.Tasks.SYNC1, TaskContract.Tasks.SYNC2),
|
||||
"${TaskContract.Tasks.ACCOUNT_TYPE}=? AND ${TaskContract.Tasks.ACCOUNT_NAME}=?",
|
||||
arrayOf(account.type, account.name), null)!!.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(0)
|
||||
val eTag = cursor.getString(1)
|
||||
val uid = cursor.getString(2)
|
||||
val values = ContentValues(4)
|
||||
values.put(TaskContract.Tasks._UID, uid)
|
||||
values.put(TaskContract.Tasks.SYNC_VERSION, eTag)
|
||||
values.putNull(TaskContract.Tasks.SYNC1)
|
||||
values.putNull(TaskContract.Tasks.SYNC2)
|
||||
Logger.log.log(Level.FINER, "Updating task $id", values)
|
||||
provider.client.update(
|
||||
ContentUris.withAppendedId(provider.tasksUri(), id).asSyncAdapter(account),
|
||||
values, null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@SuppressLint("Recycle")
|
||||
private fun update_6_7() {
|
||||
// add calendar colors
|
||||
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { provider ->
|
||||
AndroidCalendar.insertColors(provider, account)
|
||||
}
|
||||
|
||||
// update allowed WiFi settings key
|
||||
val onlySSID = accountManager.getUserData(account, "wifi_only_ssid")
|
||||
accountManager.setAndVerifyUserData(account, AccountSettings.KEY_WIFI_ONLY_SSIDS, onlySSID)
|
||||
accountManager.setAndVerifyUserData(account, "wifi_only_ssid", null)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@SuppressLint("ParcelClassLoader")
|
||||
private fun update_5_6() {
|
||||
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.use { provider ->
|
||||
val parcel = Parcel.obtain()
|
||||
try {
|
||||
// don't run syncs during the migration
|
||||
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0)
|
||||
ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 0)
|
||||
ContentResolver.cancelSync(account, null)
|
||||
|
||||
// get previous address book settings (including URL)
|
||||
val raw = ContactsContract.SyncState.get(provider, account)
|
||||
if (raw == null)
|
||||
Logger.log.info("No contacts sync state, ignoring account")
|
||||
else {
|
||||
parcel.unmarshall(raw, 0, raw.size)
|
||||
parcel.setDataPosition(0)
|
||||
val params = parcel.readBundle()!!
|
||||
val url = params.getString("url")?.toHttpUrlOrNull()
|
||||
if (url == null)
|
||||
Logger.log.info("No address book URL, ignoring account")
|
||||
else {
|
||||
// create new address book
|
||||
val info = Collection(url = url, type = Collection.TYPE_ADDRESSBOOK, displayName = account.name)
|
||||
Logger.log.log(Level.INFO, "Creating new address book account", url)
|
||||
val addressBookAccount = Account(
|
||||
LocalAddressBook.accountName(account, info), context.getString(
|
||||
R.string.account_type_address_book))
|
||||
if (!accountManager.addAccountExplicitly(addressBookAccount, null, LocalAddressBook.initialUserData(account, info.url.toString())))
|
||||
throw ContactsStorageException("Couldn't create address book account")
|
||||
|
||||
// move contacts to new address book
|
||||
Logger.log.info("Moving contacts from $account to $addressBookAccount")
|
||||
val newAccount = ContentValues(2)
|
||||
newAccount.put(ContactsContract.RawContacts.ACCOUNT_NAME, addressBookAccount.name)
|
||||
newAccount.put(ContactsContract.RawContacts.ACCOUNT_TYPE, addressBookAccount.type)
|
||||
val affected = provider.update(
|
||||
ContactsContract.RawContacts.CONTENT_URI.buildUpon()
|
||||
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
|
||||
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
|
||||
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build(),
|
||||
newAccount,
|
||||
"${ContactsContract.RawContacts.ACCOUNT_NAME}=? AND ${ContactsContract.RawContacts.ACCOUNT_TYPE}=?",
|
||||
arrayOf(account.name, account.type))
|
||||
Logger.log.info("$affected contacts moved to new address book")
|
||||
}
|
||||
|
||||
ContactsContract.SyncState.set(provider, account, null)
|
||||
}
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't migrate contacts to new address book", e)
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
// update version number so that further syncs don't repeat the migration
|
||||
accountManager.setAndVerifyUserData(account, AccountSettings.KEY_SETTINGS_VERSION, "6")
|
||||
|
||||
// request sync of new address book account
|
||||
ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 1)
|
||||
accountSettings.setSyncInterval(context.getString(R.string.address_books_authority), 4*3600)
|
||||
}
|
||||
|
||||
/* Android 7.1.1 OpenTasks fix */
|
||||
@Suppress("unused")
|
||||
private fun update_4_5() {
|
||||
// call PackageChangedReceiver which then enables/disables OpenTasks sync when it's (not) available
|
||||
TaskUtils.selectProvider(context, TaskUtils.currentProvider(context))
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun update_3_4() {
|
||||
accountSettings.setGroupMethod(GroupMethod.CATEGORIES)
|
||||
}
|
||||
|
||||
// updates from AccountSettings version 2 and below are not supported anymore
|
||||
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.TextTable
|
||||
import java.io.Writer
|
||||
|
||||
abstract class BaseDefaultsProvider(
|
||||
val context: Context,
|
||||
val settingsManager: SettingsManager
|
||||
): SettingsProvider {
|
||||
|
||||
abstract val booleanDefaults: Map<String, Boolean>
|
||||
abstract val intDefaults: Map<String, Int>
|
||||
abstract val longDefaults: Map<String, Long>
|
||||
abstract val stringDefaults: Map<String, String?>
|
||||
|
||||
|
||||
override fun canWrite() = false
|
||||
|
||||
override fun close() {
|
||||
// override this, if needed
|
||||
}
|
||||
|
||||
override fun forceReload() {
|
||||
// override this, if needed
|
||||
}
|
||||
|
||||
|
||||
override fun contains(key: String) =
|
||||
booleanDefaults.containsKey(key) ||
|
||||
intDefaults.containsKey(key) ||
|
||||
longDefaults.containsKey(key) ||
|
||||
stringDefaults.containsKey(key)
|
||||
|
||||
override fun getBoolean(key: String) = booleanDefaults[key]
|
||||
override fun getInt(key: String) = intDefaults[key]
|
||||
override fun getLong(key: String) = longDefaults[key]
|
||||
override fun getString(key: String) = stringDefaults[key]
|
||||
|
||||
override fun putBoolean(key: String, value: Boolean?) = throw NotImplementedError()
|
||||
override fun putInt(key: String, value: Int?) = throw NotImplementedError()
|
||||
override fun putLong(key: String, value: Long?) = throw NotImplementedError()
|
||||
override fun putString(key: String, value: String?) = throw NotImplementedError()
|
||||
|
||||
override fun remove(key: String) = throw NotImplementedError()
|
||||
|
||||
|
||||
override fun dump(writer: Writer) {
|
||||
val strValues = mutableMapOf<String, String?>()
|
||||
strValues.putAll(booleanDefaults.mapValues { (_, value) -> value.toString() })
|
||||
strValues.putAll(intDefaults.mapValues { (_, value) -> value.toString() })
|
||||
strValues.putAll(longDefaults.mapValues { (_, value) -> value.toString() })
|
||||
strValues.putAll(stringDefaults)
|
||||
|
||||
val table = TextTable("Setting", "Value")
|
||||
for ((key, value) in strValues.toSortedMap())
|
||||
table.addLine(key, value)
|
||||
writer.write(table.toString())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.content.Context
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntKey
|
||||
import dagger.multibindings.IntoMap
|
||||
import javax.inject.Inject
|
||||
|
||||
class DefaultsProvider(
|
||||
context: Context,
|
||||
settingsManager: SettingsManager
|
||||
): BaseDefaultsProvider(context, settingsManager) {
|
||||
|
||||
override val booleanDefaults = mutableMapOf(
|
||||
Pair(Settings.DISTRUST_SYSTEM_CERTIFICATES, false),
|
||||
Pair(Settings.FORCE_READ_ONLY_ADDRESSBOOKS, false),
|
||||
Pair(Settings.IGNORE_VPN_NETWORK_CAPABILITY, true)
|
||||
)
|
||||
|
||||
override val intDefaults = mapOf(
|
||||
Pair(Settings.PRESELECT_COLLECTIONS, Settings.PRESELECT_COLLECTIONS_NONE),
|
||||
Pair(Settings.PROXY_TYPE, Settings.PROXY_TYPE_SYSTEM),
|
||||
Pair(Settings.PROXY_PORT, 9050) // Orbot SOCKS
|
||||
)
|
||||
|
||||
override val longDefaults = mapOf<String, Long>(
|
||||
Pair(Settings.DEFAULT_SYNC_INTERVAL, 4*3600) /* 4 hours */
|
||||
)
|
||||
|
||||
override val stringDefaults = mapOf(
|
||||
Pair(Settings.PROXY_HOST, "localhost"),
|
||||
Pair(Settings.PRESELECT_COLLECTIONS_EXCLUDED, "/z-app-generated--contactsinteraction--recent/") // Nextcloud "Recently Contacted" address book
|
||||
)
|
||||
|
||||
class Factory @Inject constructor(): SettingsProviderFactory {
|
||||
override fun getProviders(context: Context, settingsManager: SettingsManager) =
|
||||
listOf(DefaultsProvider(context, settingsManager))
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class DefaultsProviderFactoryModule {
|
||||
@Binds
|
||||
@IntoMap @IntKey(/* priority */ 0)
|
||||
abstract fun factory(impl: Factory): SettingsProviderFactory
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.content.Context
|
||||
|
||||
interface SettingsProviderFactory {
|
||||
|
||||
fun getProviders(context: Context, settingsManager: SettingsManager): Iterable<SettingsProvider>
|
||||
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.util.setAndVerifyUserData
|
||||
|
||||
object AccountUtils {
|
||||
|
||||
/**
|
||||
* Creates an account and makes sure the user data are set correctly.
|
||||
*
|
||||
* @param context operating context
|
||||
* @param account account to create
|
||||
* @param userData user data to set
|
||||
*
|
||||
* @return whether the account has been created
|
||||
*
|
||||
* @throws IllegalArgumentException when user data contains non-String values
|
||||
* @throws IllegalStateException if user data can't be set
|
||||
*/
|
||||
fun createAccount(context: Context, account: Account, userData: Bundle, password: String? = null): Boolean {
|
||||
// validate user data
|
||||
for (key in userData.keySet()) {
|
||||
userData.get(key)?.let { entry ->
|
||||
if (entry !is String)
|
||||
throw IllegalArgumentException("userData[$key] is ${entry::class.java} (expected: String)")
|
||||
}
|
||||
}
|
||||
|
||||
// create account
|
||||
val manager = AccountManager.get(context)
|
||||
if (!manager.addAccountExplicitly(account, password, userData))
|
||||
return false
|
||||
|
||||
// Android seems to lose the initial user data sometimes, so set it a second time if that happens
|
||||
// https://forums.bitfire.at/post/11644
|
||||
if (!verifyUserData(context, account, userData))
|
||||
for (key in userData.keySet())
|
||||
manager.setAndVerifyUserData(account, key, userData.getString(key))
|
||||
|
||||
if (!verifyUserData(context, account, userData))
|
||||
throw IllegalStateException("Android doesn't store user data in account")
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun verifyUserData(context: Context, account: Account, userData: Bundle): Boolean {
|
||||
val accountManager = AccountManager.get(context)
|
||||
userData.keySet().forEach { key ->
|
||||
val stored = accountManager.getUserData(account, key)
|
||||
val expected = userData.getString(key)
|
||||
if (stored != expected) {
|
||||
Logger.log.warning("Stored user data \"$stored\" differs from expected data \"$expected\" for $key")
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import java.util.concurrent.Semaphore
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Level
|
||||
|
||||
@HiltWorker
|
||||
class AccountsCleanupWorker @AssistedInject constructor(
|
||||
@Assisted appContext: Context,
|
||||
@Assisted workerParameters: WorkerParameters,
|
||||
val db: AppDatabase
|
||||
): Worker(appContext, workerParameters) {
|
||||
|
||||
companion object {
|
||||
const val NAME = "accounts-cleanup"
|
||||
|
||||
private val mutex = Semaphore(1)
|
||||
/**
|
||||
* Prevents account cleanup from being run until `unlockAccountsCleanup` is called.
|
||||
* Can only be active once at the same time globally (blocking).
|
||||
*/
|
||||
fun lockAccountsCleanup() = mutex.acquire()
|
||||
/** Must be called exactly one time after calling `lockAccountsCleanup`. */
|
||||
fun unlockAccountsCleanup() = mutex.release()
|
||||
|
||||
fun enqueue(context: Context) {
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(NAME, ExistingWorkPolicy.KEEP,
|
||||
OneTimeWorkRequestBuilder<AccountsCleanupWorker>()
|
||||
.setInitialDelay(15, TimeUnit.SECONDS) // wait some time before cleaning up accouts
|
||||
.build())
|
||||
}
|
||||
}
|
||||
|
||||
override fun doWork(): Result {
|
||||
lockAccountsCleanup()
|
||||
try {
|
||||
val accountManager = AccountManager.get(applicationContext)
|
||||
cleanupAccounts(applicationContext, accountManager.accounts)
|
||||
} finally {
|
||||
unlockAccountsCleanup()
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private fun cleanupAccounts(context: Context, accounts: Array<out Account>) {
|
||||
Logger.log.log(Level.INFO, "Cleaning up accounts. Current accounts:", accounts)
|
||||
|
||||
val mainAccountType = context.getString(R.string.account_type)
|
||||
val mainAccountNames = accounts
|
||||
.filter { account -> account.type == mainAccountType }
|
||||
.map { it.name }
|
||||
|
||||
val addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||
val addressBooks = accounts
|
||||
.filter { account -> account.type == addressBookAccountType }
|
||||
.map { addressBookAccount -> LocalAddressBook(context, addressBookAccount, null) }
|
||||
for (addressBook in addressBooks) {
|
||||
try {
|
||||
val mainAccount = addressBook.mainAccount
|
||||
if (mainAccount == null || !mainAccountNames.contains(mainAccount.name))
|
||||
// the main account for this address book doesn't exist anymore
|
||||
addressBook.delete()
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't delete address book account", e)
|
||||
}
|
||||
}
|
||||
|
||||
// delete orphaned services in DB
|
||||
val serviceDao = db.serviceDao()
|
||||
if (mainAccountNames.isEmpty())
|
||||
serviceDao.deleteAll()
|
||||
else
|
||||
serviceDao.deleteExceptAccounts(mainAccountNames.toTypedArray())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.accounts.OnAccountsUpdateListener
|
||||
import android.content.Context
|
||||
import androidx.annotation.AnyThread
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
class AccountsUpdatedListener private constructor(
|
||||
val context: Context
|
||||
): OnAccountsUpdateListener {
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AccountsUpdatedListenerModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun accountsUpdatedListener(@ApplicationContext context: Context) = AccountsUpdatedListener(context)
|
||||
}
|
||||
|
||||
fun listen() {
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.addOnAccountsUpdatedListener(this, null, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the system accounts have been updated. The interesting case for us is when
|
||||
* a DAVx5 account has been removed. Then we enqueue an [AccountsCleanupWorker] to remove
|
||||
* the orphaned entries from the database.
|
||||
*/
|
||||
@AnyThread
|
||||
override fun onAccountsUpdated(accounts: Array<out Account>) {
|
||||
AccountsCleanupWorker.enqueue(context)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
|
||||
class AddressBookProvider: ContentProvider() {
|
||||
|
||||
override fun onCreate() = false
|
||||
override fun insert(p0: Uri, p1: ContentValues?) = null
|
||||
override fun query(p0: Uri, p1: Array<out String>?, p2: String?, p3: Array<out String>?, p4: String?) = null
|
||||
override fun update(p0: Uri, p1: ContentValues?, p2: String?, p3: Array<out String>?) = 0
|
||||
override fun delete(p0: Uri, p1: String?, p2: Array<out String>?) = 0
|
||||
override fun getType(p0: Uri) = null
|
||||
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.ContactsContract
|
||||
import androidx.core.content.ContextCompat
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.util.setAndVerifyUserData
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Sync logic for address books
|
||||
*/
|
||||
class AddressBookSyncer(
|
||||
context: Context
|
||||
) : Syncer(context) {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface AddressBooksSyncAdapterEntryPoint {
|
||||
fun settingsManager(): SettingsManager
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PREVIOUS_GROUP_METHOD = "previous_group_method"
|
||||
}
|
||||
|
||||
val entryPoint = EntryPointAccessors.fromApplication(context, AddressBooksSyncAdapterEntryPoint::class.java)
|
||||
val settingsManager = entryPoint.settingsManager()
|
||||
|
||||
|
||||
override fun sync(
|
||||
account: Account,
|
||||
extras: Array<String>,
|
||||
authority: String, // address book authority (not contacts authority)
|
||||
httpClient: Lazy<HttpClient>,
|
||||
provider: ContentProviderClient, // for noop address book provider (not for contacts provider)
|
||||
syncResult: SyncResult
|
||||
) {
|
||||
try {
|
||||
if (updateLocalAddressBooks(account, syncResult)) {
|
||||
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.use { contactsProvider ->
|
||||
for (addressBookAccount in LocalAddressBook.findAll(context, null, account).map { it.account }) {
|
||||
Logger.log.info("Synchronizing address book $addressBookAccount")
|
||||
syncAddresBook(
|
||||
addressBookAccount,
|
||||
extras,
|
||||
ContactsContract.AUTHORITY,
|
||||
httpClient,
|
||||
contactsProvider,
|
||||
syncResult
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't sync address books", e)
|
||||
}
|
||||
|
||||
Logger.log.info("Address book sync complete")
|
||||
}
|
||||
|
||||
private fun updateLocalAddressBooks(account: Account, syncResult: SyncResult): Boolean {
|
||||
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV)
|
||||
|
||||
val remoteAddressBooks = mutableMapOf<HttpUrl, Collection>()
|
||||
if (service != null)
|
||||
for (collection in db.collectionDao().getByServiceAndSync(service.id))
|
||||
remoteAddressBooks[collection.url] = collection
|
||||
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
|
||||
if (remoteAddressBooks.isEmpty())
|
||||
Logger.log.info("No contacts permission, but no address book selected for synchronization")
|
||||
else
|
||||
Logger.log.warning("No contacts permission, but address books are selected for synchronization")
|
||||
return false
|
||||
}
|
||||
|
||||
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY).use { contactsProvider ->
|
||||
if (contactsProvider == null) {
|
||||
Logger.log.severe("Couldn't access contacts provider")
|
||||
syncResult.databaseError = true
|
||||
return false
|
||||
}
|
||||
|
||||
val forceAllReadOnly = settingsManager.getBoolean(Settings.FORCE_READ_ONLY_ADDRESSBOOKS)
|
||||
|
||||
// delete/update local address books
|
||||
for (addressBook in LocalAddressBook.findAll(context, contactsProvider, account)) {
|
||||
val url = addressBook.url.toHttpUrl()
|
||||
val info = remoteAddressBooks[url]
|
||||
if (info == null) {
|
||||
Logger.log.log(Level.INFO, "Deleting obsolete local address book", url)
|
||||
addressBook.delete()
|
||||
} else {
|
||||
// remote CollectionInfo found for this local collection, update data
|
||||
try {
|
||||
Logger.log.log(Level.FINE, "Updating local address book $url", info)
|
||||
addressBook.update(info, forceAllReadOnly)
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't rename address book account", e)
|
||||
}
|
||||
// we already have a local address book for this remote collection, don't take into consideration anymore
|
||||
remoteAddressBooks -= url
|
||||
}
|
||||
}
|
||||
|
||||
// create new local address books
|
||||
for ((_, info) in remoteAddressBooks) {
|
||||
Logger.log.log(Level.INFO, "Adding local address book", info)
|
||||
LocalAddressBook.create(context, contactsProvider, account, info, forceAllReadOnly)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
fun syncAddresBook(
|
||||
account: Account,
|
||||
extras: Array<String>,
|
||||
authority: String,
|
||||
httpClient: Lazy<HttpClient>,
|
||||
provider: ContentProviderClient,
|
||||
syncResult: SyncResult
|
||||
) {
|
||||
try {
|
||||
val accountSettings = AccountSettings(context, account)
|
||||
val addressBook = LocalAddressBook(context, account, provider)
|
||||
|
||||
// handle group method change
|
||||
val groupMethod = accountSettings.getGroupMethod().name
|
||||
accountSettings.accountManager.getUserData(account, PREVIOUS_GROUP_METHOD)?.let { previousGroupMethod ->
|
||||
if (previousGroupMethod != groupMethod) {
|
||||
Logger.log.info("Group method changed, deleting all local contacts/groups")
|
||||
|
||||
// delete all local contacts and groups so that they will be downloaded again
|
||||
provider.delete(addressBook.syncAdapterURI(ContactsContract.RawContacts.CONTENT_URI), null, null)
|
||||
provider.delete(addressBook.syncAdapterURI(ContactsContract.Groups.CONTENT_URI), null, null)
|
||||
|
||||
// reset sync state
|
||||
addressBook.syncState = null
|
||||
}
|
||||
}
|
||||
accountSettings.accountManager.setAndVerifyUserData(account, PREVIOUS_GROUP_METHOD, groupMethod)
|
||||
|
||||
Logger.log.info("Synchronizing address book: ${addressBook.url}")
|
||||
Logger.log.info("Taking settings from: ${addressBook.mainAccount}")
|
||||
|
||||
ContactsSyncManager(context, account, accountSettings, httpClient.value, extras, authority, syncResult, provider, addressBook).performSync()
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't sync contacts", e)
|
||||
}
|
||||
Logger.log.info("Contacts sync complete")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SyncResult
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.wifi.WifiManager
|
||||
import android.provider.CalendarContract
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.map
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkQuery
|
||||
import androidx.work.WorkerParameters
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.network.ConnectionUtils
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible
|
||||
import at.bitfire.davdroid.ui.account.WifiPermissionsActivity
|
||||
import at.bitfire.davdroid.util.PermissionUtils
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Collections
|
||||
import java.util.logging.Level
|
||||
|
||||
abstract class BaseSyncWorker(
|
||||
appContext: Context,
|
||||
val workerParams: WorkerParameters
|
||||
) : CoroutineWorker(appContext, workerParams) {
|
||||
|
||||
companion object {
|
||||
|
||||
// common worker input parameters
|
||||
const val INPUT_ACCOUNT_NAME = "accountName"
|
||||
const val INPUT_ACCOUNT_TYPE = "accountType"
|
||||
const val INPUT_AUTHORITY = "authority"
|
||||
|
||||
/** set to true for user-initiated sync that skips network checks */
|
||||
const val INPUT_MANUAL = "manual"
|
||||
|
||||
/**
|
||||
* How often this work will be retried to run after soft (network) errors.
|
||||
*
|
||||
* Retry strategy is defined in work request ([enqueue]).
|
||||
*/
|
||||
internal const val MAX_RUN_ATTEMPTS = 5
|
||||
|
||||
/**
|
||||
* Set of currently running syncs, identified by their [commonTag].
|
||||
*/
|
||||
private val runningSyncs = Collections.synchronizedSet(HashSet<String>())
|
||||
|
||||
/**
|
||||
* Stops running sync workers and removes pending sync workers from queue, for all authorities.
|
||||
*/
|
||||
fun cancelAllWork(context: Context, account: Account) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
for (authority in SyncUtils.syncAuthorities(context)) {
|
||||
workManager.cancelUniqueWork(OneTimeSyncWorker.workerName(account, authority))
|
||||
workManager.cancelUniqueWork(PeriodicSyncWorker.workerName(account, authority))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This tag shall be added to every worker that is enqueued by a subclass.
|
||||
*/
|
||||
fun commonTag(account: Account, authority: String): String =
|
||||
"sync-$authority ${account.type}/${account.name}"
|
||||
|
||||
/**
|
||||
* Will tell whether >0 sync workers (both [PeriodicSyncWorker] and [OneTimeSyncWorker])
|
||||
* exist, belonging to given account and authorities, and which are/is in the given worker state.
|
||||
*
|
||||
* @param workStates list of states of workers to match
|
||||
* @param account the account which the workers belong to
|
||||
* @param authorities type of sync work, ie [CalendarContract.AUTHORITY]
|
||||
* @param whichTag function to generate tag that should be observed for given account and authority
|
||||
* @return *true* if at least one worker with matching query was found; *false* otherwise
|
||||
*/
|
||||
fun exists(
|
||||
context: Context,
|
||||
workStates: List<WorkInfo.State>,
|
||||
account: Account? = null,
|
||||
authorities: List<String>? = null,
|
||||
whichTag: (account: Account, authority: String) -> String = { account, authority ->
|
||||
commonTag(account, authority)
|
||||
}
|
||||
): LiveData<Boolean> {
|
||||
val workQuery = WorkQuery.Builder
|
||||
.fromStates(workStates)
|
||||
if (account != null && authorities != null)
|
||||
workQuery.addTags(
|
||||
authorities.map { authority -> whichTag(account, authority) }
|
||||
)
|
||||
return WorkManager.getInstance(context)
|
||||
.getWorkInfosLiveData(workQuery.build()).map { workInfoList ->
|
||||
workInfoList.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether user imposed sync conditions from settings are met:
|
||||
* - Sync only on WiFi?
|
||||
* - Sync only on specific WiFi (SSID)?
|
||||
*
|
||||
* @param accountSettings Account settings of the account to check (and is to be synced)
|
||||
* @return *true* if conditions are met; *false* if not
|
||||
*/
|
||||
fun wifiConditionsMet(context: Context, accountSettings: AccountSettings): Boolean {
|
||||
// May we sync without WiFi?
|
||||
if (!accountSettings.getSyncWifiOnly())
|
||||
return true // yes, continue
|
||||
|
||||
// WiFi required, is it available?
|
||||
val connectivityManager = context.getSystemService<ConnectivityManager>()!!
|
||||
if (!ConnectionUtils.wifiAvailable(connectivityManager)) {
|
||||
Logger.log.info("Not on connected WiFi, stopping")
|
||||
return false
|
||||
}
|
||||
// If execution reaches this point, we're on a connected WiFi
|
||||
|
||||
// Check whether we are connected to the correct WiFi (in case SSID was provided)
|
||||
return correctWifiSsid(context, accountSettings)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether we are connected to the correct wifi (SSID) defined by user in the
|
||||
* account settings.
|
||||
*
|
||||
* Note: Should be connected to some wifi before calling.
|
||||
*
|
||||
* @param accountSettings Settings of account to check
|
||||
* @return *true* if connected to the correct wifi OR no wifi names were specified in
|
||||
* account settings; *false* otherwise
|
||||
*/
|
||||
internal fun correctWifiSsid(context: Context, accountSettings: AccountSettings): Boolean {
|
||||
accountSettings.getSyncWifiOnlySSIDs()?.let { onlySSIDs ->
|
||||
// check required permissions and location status
|
||||
if (!PermissionUtils.canAccessWifiSsid(context)) {
|
||||
// not all permissions granted; show notification
|
||||
val intent = Intent(context, WifiPermissionsActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
intent.putExtra(WifiPermissionsActivity.EXTRA_ACCOUNT, accountSettings.account)
|
||||
PermissionUtils.notifyPermissions(context, intent)
|
||||
|
||||
Logger.log.warning("Can't access WiFi SSID, aborting sync")
|
||||
return false
|
||||
}
|
||||
|
||||
val wifi = context.getSystemService<WifiManager>()!!
|
||||
val info = wifi.connectionInfo
|
||||
if (info == null || !onlySSIDs.contains(info.ssid.trim('"'))) {
|
||||
Logger.log.info("Connected to wrong WiFi network (${info.ssid}), aborting sync")
|
||||
return false
|
||||
}
|
||||
Logger.log.fine("Connected to WiFi network ${info.ssid}")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
private val dispatcher = SyncWorkDispatcher.getInstance(applicationContext)
|
||||
private val notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
// ensure we got the required arguments
|
||||
val account = Account(
|
||||
inputData.getString(INPUT_ACCOUNT_NAME) ?: throw IllegalArgumentException("$INPUT_ACCOUNT_NAME required"),
|
||||
inputData.getString(INPUT_ACCOUNT_TYPE) ?: throw IllegalArgumentException("$INPUT_ACCOUNT_TYPE required")
|
||||
)
|
||||
val authority = inputData.getString(INPUT_AUTHORITY) ?: throw IllegalArgumentException("$INPUT_AUTHORITY required")
|
||||
|
||||
val syncTag = commonTag(account, authority)
|
||||
Logger.log.info("${javaClass.simpleName} called for $syncTag")
|
||||
|
||||
if (!runningSyncs.add(syncTag)) {
|
||||
Logger.log.info("There's already another worker running for $syncTag, skipping")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
try {
|
||||
val accountSettings = try {
|
||||
AccountSettings(applicationContext, account)
|
||||
} catch (e: InvalidAccountException) {
|
||||
val workId = workerParams.id
|
||||
Logger.log.warning("Account $account doesn't exist anymore, cancelling worker $workId")
|
||||
|
||||
val workManager = WorkManager.getInstance(applicationContext)
|
||||
workManager.cancelWorkById(workId)
|
||||
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
if (inputData.getBoolean(INPUT_MANUAL, false))
|
||||
Logger.log.info("Manual sync, skipping network checks")
|
||||
else {
|
||||
// check internet connection
|
||||
val ignoreVpns = accountSettings.getIgnoreVpns()
|
||||
val connectivityManager = applicationContext.getSystemService<ConnectivityManager>()!!
|
||||
if (!ConnectionUtils.internetAvailable(connectivityManager, ignoreVpns)) {
|
||||
Logger.log.info("WorkManager started SyncWorker without Internet connection. Aborting.")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
// check WiFi restriction
|
||||
if (!wifiConditionsMet(applicationContext, accountSettings)) {
|
||||
Logger.log.info("WiFi conditions not met. Won't run periodic sync.")
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
return doSyncWork(account, authority, accountSettings)
|
||||
} finally {
|
||||
Logger.log.info("${javaClass.simpleName} finished for $syncTag")
|
||||
runningSyncs -= syncTag
|
||||
}
|
||||
}
|
||||
|
||||
open suspend fun doSyncWork(
|
||||
account: Account,
|
||||
authority: String,
|
||||
accountSettings: AccountSettings
|
||||
): Result = withContext(dispatcher) {
|
||||
Logger.log.info("Running ${javaClass.name}: account=$account, authority=$authority")
|
||||
|
||||
// What are we going to sync? Select syncer based on authority
|
||||
val syncer: Syncer = when (authority) {
|
||||
applicationContext.getString(R.string.address_books_authority) ->
|
||||
AddressBookSyncer(applicationContext)
|
||||
CalendarContract.AUTHORITY ->
|
||||
CalendarSyncer(applicationContext)
|
||||
TaskProvider.ProviderName.JtxBoard.authority ->
|
||||
JtxSyncer(applicationContext)
|
||||
TaskProvider.ProviderName.OpenTasks.authority,
|
||||
TaskProvider.ProviderName.TasksOrg.authority ->
|
||||
TaskSyncer(applicationContext)
|
||||
else ->
|
||||
throw IllegalArgumentException("Invalid authority $authority")
|
||||
}
|
||||
|
||||
// pass possibly supplied flags to the selected syncer
|
||||
val extras = mutableListOf<String>()
|
||||
when (inputData.getInt(OneTimeSyncWorker.ARG_RESYNC, OneTimeSyncWorker.NO_RESYNC)) {
|
||||
OneTimeSyncWorker.RESYNC -> extras.add(Syncer.SYNC_EXTRAS_RESYNC)
|
||||
OneTimeSyncWorker.FULL_RESYNC -> extras.add(Syncer.SYNC_EXTRAS_FULL_RESYNC)
|
||||
}
|
||||
if (inputData.getBoolean(OneTimeSyncWorker.ARG_UPLOAD, false))
|
||||
// Comes in through SyncAdapterService and is used only by ContactsSyncManager for an Android 7 workaround.
|
||||
extras.add(ContentResolver.SYNC_EXTRAS_UPLOAD)
|
||||
|
||||
// acquire ContentProviderClient of authority to be synced
|
||||
try {
|
||||
applicationContext.contentResolver.acquireContentProviderClient(authority)
|
||||
} catch (e: SecurityException) {
|
||||
Logger.log.log(Level.WARNING, "Missing permissions to acquire ContentProviderClient for $authority", e)
|
||||
null
|
||||
}.use { provider ->
|
||||
if (provider == null) {
|
||||
Logger.log.warning("Couldn't acquire ContentProviderClient for $authority")
|
||||
return@withContext Result.failure()
|
||||
}
|
||||
|
||||
val result = SyncResult()
|
||||
// Start syncing. We still use the sync adapter framework's SyncResult to pass the sync results, but this
|
||||
// is only for legacy reasons and can be replaced by an own result class in the future.
|
||||
runInterruptible {
|
||||
syncer.onPerformSync(account, extras.toTypedArray(), authority, provider, result)
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
if (result.hasError()) {
|
||||
val syncResult = Data.Builder()
|
||||
.putString("syncresult", result.toString())
|
||||
.putString("syncResultStats", result.stats.toString())
|
||||
.build()
|
||||
|
||||
val softErrorNotificationTag = account.type + "-" + account.name + "-" + authority
|
||||
|
||||
// On soft errors the sync is retried a few times before considered failed
|
||||
if (result.hasSoftError()) {
|
||||
Logger.log.warning("Soft error while syncing: result=$result, stats=${result.stats}")
|
||||
if (runAttemptCount < MAX_RUN_ATTEMPTS) {
|
||||
val blockDuration = result.delayUntil - System.currentTimeMillis() / 1000
|
||||
Logger.log.warning("Waiting for $blockDuration seconds, before retrying ...")
|
||||
|
||||
// We block the SyncWorker here so that it won't be started by the sync framework immediately again.
|
||||
// This should be replaced by proper work scheduling as soon as we don't depend on the sync framework anymore.
|
||||
if (blockDuration > 0)
|
||||
delay(blockDuration * 1000)
|
||||
|
||||
Logger.log.warning("Retrying on soft error (attempt $runAttemptCount of $MAX_RUN_ATTEMPTS)")
|
||||
return@withContext Result.retry()
|
||||
}
|
||||
|
||||
Logger.log.warning("Max retries on soft errors reached ($runAttemptCount of $MAX_RUN_ATTEMPTS). Treating as failed")
|
||||
|
||||
notificationManager.notifyIfPossible(
|
||||
softErrorNotificationTag,
|
||||
NotificationUtils.NOTIFY_SYNC_ERROR,
|
||||
NotificationUtils.newBuilder(applicationContext, NotificationUtils.CHANNEL_SYNC_IO_ERRORS)
|
||||
.setSmallIcon(R.drawable.ic_sync_problem_notify)
|
||||
.setContentTitle(account.name)
|
||||
.setContentText(applicationContext.getString(R.string.sync_error_retry_limit_reached))
|
||||
.setSubText(account.name)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.build()
|
||||
)
|
||||
|
||||
return@withContext Result.failure(syncResult)
|
||||
}
|
||||
|
||||
// If no soft error found, dismiss sync error notification
|
||||
notificationManager.cancel(
|
||||
softErrorNotificationTag,
|
||||
NotificationUtils.NOTIFY_SYNC_ERROR
|
||||
)
|
||||
|
||||
// On a hard error - fail with an error message
|
||||
// Note: SyncManager should have notified the user
|
||||
if (result.hasHardError()) {
|
||||
Logger.log.warning("Hard error while syncing: result=$result, stats=${result.stats}")
|
||||
return@withContext Result.failure(syncResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return@withContext Result.success()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import at.bitfire.dav4jvm.DavCalendar
|
||||
import at.bitfire.dav4jvm.MultiResponseCallback
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarData
|
||||
import at.bitfire.dav4jvm.property.caldav.GetCTag
|
||||
import at.bitfire.dav4jvm.property.caldav.MaxResourceSize
|
||||
import at.bitfire.dav4jvm.property.caldav.ScheduleTag
|
||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||
import at.bitfire.dav4jvm.property.webdav.SupportedReportSet
|
||||
import at.bitfire.dav4jvm.property.webdav.SyncToken
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.resource.LocalCalendar
|
||||
import at.bitfire.davdroid.resource.LocalEvent
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.InvalidCalendarException
|
||||
import at.bitfire.ical4android.util.DateUtils
|
||||
import net.fortuna.ical4j.model.Component
|
||||
import net.fortuna.ical4j.model.component.VAlarm
|
||||
import net.fortuna.ical4j.model.property.Action
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.apache.commons.io.FileUtils
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.Reader
|
||||
import java.io.StringReader
|
||||
import java.time.Duration
|
||||
import java.time.ZonedDateTime
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Synchronization manager for CalDAV collections; handles events (VEVENT)
|
||||
*/
|
||||
class CalendarSyncManager(
|
||||
context: Context,
|
||||
account: Account,
|
||||
accountSettings: AccountSettings,
|
||||
extras: Array<String>,
|
||||
httpClient: HttpClient,
|
||||
authority: String,
|
||||
syncResult: SyncResult,
|
||||
localCalendar: LocalCalendar
|
||||
): SyncManager<LocalEvent, LocalCalendar, DavCalendar>(context, account, accountSettings, httpClient, extras, authority, syncResult, localCalendar) {
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
collectionURL = (localCollection.name ?: return false).toHttpUrlOrNull() ?: return false
|
||||
davCollection = DavCalendar(httpClient.okHttpClient, collectionURL)
|
||||
|
||||
// if there are dirty exceptions for events, mark their master events as dirty, too
|
||||
localCollection.processDirtyExceptions()
|
||||
|
||||
// now find dirty events that have no instances and set them to deleted
|
||||
localCollection.deleteDirtyEventsWithoutInstances()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun queryCapabilities(): SyncState? =
|
||||
remoteExceptionContext {
|
||||
var syncState: SyncState? = null
|
||||
it.propfind(0, MaxResourceSize.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF) {
|
||||
response[MaxResourceSize::class.java]?.maxSize?.let { maxSize ->
|
||||
Logger.log.info("Calendar accepts events up to ${FileUtils.byteCountToDisplaySize(maxSize)}")
|
||||
}
|
||||
|
||||
response[SupportedReportSet::class.java]?.let { supported ->
|
||||
hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION)
|
||||
}
|
||||
syncState = syncState(response)
|
||||
}
|
||||
}
|
||||
|
||||
Logger.log.info("Calendar supports Collection Sync: $hasCollectionSync")
|
||||
syncState
|
||||
}
|
||||
|
||||
override fun syncAlgorithm() =
|
||||
if (accountSettings.getTimeRangePastDays() != null || !hasCollectionSync)
|
||||
SyncAlgorithm.PROPFIND_REPORT
|
||||
else
|
||||
SyncAlgorithm.COLLECTION_SYNC
|
||||
|
||||
override fun processLocallyDeleted(): Boolean {
|
||||
if (localCollection.readOnly) {
|
||||
var modified = false
|
||||
for (event in localCollection.findDeleted()) {
|
||||
Logger.log.warning("Restoring locally deleted event (read-only calendar!)")
|
||||
localExceptionContext(event) { it.resetDeleted() }
|
||||
modified = true
|
||||
}
|
||||
|
||||
// This is unfortunately ugly: When an event has been inserted to a read-only calendar
|
||||
// it's not enough to force synchronization (by returning true),
|
||||
// but we also need to make sure all events are downloaded again.
|
||||
if (modified)
|
||||
localCollection.lastSyncState = null
|
||||
|
||||
return modified
|
||||
}
|
||||
// mirror deletions to remote collection (DELETE)
|
||||
return super.processLocallyDeleted()
|
||||
}
|
||||
|
||||
override fun uploadDirty(): Boolean {
|
||||
var modified = false
|
||||
if (localCollection.readOnly) {
|
||||
for (event in localCollection.findDirty()) {
|
||||
Logger.log.warning("Resetting locally modified event to ETag=null (read-only calendar!)")
|
||||
localExceptionContext(event) { it.clearDirty(null, null) }
|
||||
modified = true
|
||||
}
|
||||
|
||||
// This is unfortunately ugly: When an event has been inserted to a read-only calendar
|
||||
// it's not enough to force synchronization (by returning true),
|
||||
// but we also need to make sure all events are downloaded again.
|
||||
if (modified)
|
||||
localCollection.lastSyncState = null
|
||||
}
|
||||
|
||||
// generate UID/file name for newly created events
|
||||
val superModified = super.uploadDirty()
|
||||
|
||||
// return true when any operation returned true
|
||||
return modified or superModified
|
||||
}
|
||||
|
||||
override fun generateUpload(resource: LocalEvent): RequestBody = localExceptionContext(resource) {
|
||||
val event = requireNotNull(resource.event)
|
||||
Logger.log.log(Level.FINE, "Preparing upload of event ${resource.fileName}", event)
|
||||
|
||||
val os = ByteArrayOutputStream()
|
||||
event.write(os)
|
||||
|
||||
os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
|
||||
}
|
||||
|
||||
override fun listAllRemote(callback: MultiResponseCallback) {
|
||||
// calculate time range limits
|
||||
val limitStart = accountSettings.getTimeRangePastDays()?.let { pastDays ->
|
||||
ZonedDateTime.now().minusDays(pastDays.toLong()).toInstant()
|
||||
}
|
||||
|
||||
return remoteExceptionContext { remote ->
|
||||
Logger.log.info("Querying events since $limitStart")
|
||||
remote.calendarQuery(Component.VEVENT, limitStart, null, callback)
|
||||
}
|
||||
}
|
||||
|
||||
override fun downloadRemote(bunch: List<HttpUrl>) {
|
||||
Logger.log.info("Downloading ${bunch.size} iCalendars: $bunch")
|
||||
remoteExceptionContext {
|
||||
it.multiget(bunch) { response, _ ->
|
||||
responseExceptionContext(response) {
|
||||
if (!response.isSuccess()) {
|
||||
Logger.log.warning("Received non-successful multiget response for ${response.href}")
|
||||
return@responseExceptionContext
|
||||
}
|
||||
|
||||
val eTag = response[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
val scheduleTag = response[ScheduleTag::class.java]?.scheduleTag
|
||||
|
||||
val calendarData = response[CalendarData::class.java]
|
||||
val iCal = calendarData?.iCalendar
|
||||
?: throw DavException("Received multi-get response without calendar data")
|
||||
|
||||
processVEvent(DavUtils.lastSegmentOfUrl(response.href), eTag, scheduleTag, StringReader(iCal))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun postProcess() {}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun processVEvent(fileName: String, eTag: String, scheduleTag: String?, reader: Reader) {
|
||||
val events: List<Event>
|
||||
try {
|
||||
events = Event.eventsFromReader(reader)
|
||||
} catch (e: InvalidCalendarException) {
|
||||
Logger.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e)
|
||||
notifyInvalidResource(e, fileName)
|
||||
return
|
||||
}
|
||||
|
||||
if (events.size == 1) {
|
||||
val event = events.first()
|
||||
|
||||
// set default reminder for non-full-day events, if requested
|
||||
val defaultAlarmMinBefore = accountSettings.getDefaultAlarm()
|
||||
if (defaultAlarmMinBefore != null && DateUtils.isDateTime(event.dtStart) && event.alarms.isEmpty()) {
|
||||
val alarm = VAlarm(Duration.ofMinutes(-defaultAlarmMinBefore.toLong())).apply {
|
||||
// Sets METHOD_ALERT instead of METHOD_DEFAULT in the calendar provider.
|
||||
// Needed for calendars to actually show a notification.
|
||||
properties += Action.DISPLAY
|
||||
}
|
||||
Logger.log.log(Level.FINE, "${event.uid}: Adding default alarm", alarm)
|
||||
event.alarms += alarm
|
||||
}
|
||||
|
||||
// update local event, if it exists
|
||||
localExceptionContext(localCollection.findByName(fileName)) { local ->
|
||||
if (local != null) {
|
||||
Logger.log.log(Level.INFO, "Updating $fileName in local calendar", event)
|
||||
local.eTag = eTag
|
||||
local.scheduleTag = scheduleTag
|
||||
local.update(event)
|
||||
syncResult.stats.numUpdates++
|
||||
} else {
|
||||
Logger.log.log(Level.INFO, "Adding $fileName to local calendar", event)
|
||||
localExceptionContext(LocalEvent(localCollection, event, fileName, eTag, scheduleTag, LocalResource.FLAG_REMOTELY_PRESENT)) {
|
||||
it.add()
|
||||
}
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
}
|
||||
} else
|
||||
Logger.log.info("Received VCALENDAR with not exactly one VEVENT with UID and without RECURRENCE-ID; ignoring $fileName")
|
||||
}
|
||||
|
||||
override fun notifyInvalidResourceTitle(): String =
|
||||
context.getString(R.string.sync_invalid_event)
|
||||
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.provider.CalendarContract
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.resource.LocalCalendar
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Sync logic for calendars
|
||||
*/
|
||||
class CalendarSyncer(context: Context): Syncer(context) {
|
||||
|
||||
override fun sync(
|
||||
account: Account,
|
||||
extras: Array<String>,
|
||||
authority: String,
|
||||
httpClient: Lazy<HttpClient>,
|
||||
provider: ContentProviderClient,
|
||||
syncResult: SyncResult
|
||||
) {
|
||||
try {
|
||||
val accountSettings = AccountSettings(context, account)
|
||||
|
||||
if (accountSettings.getEventColors())
|
||||
AndroidCalendar.insertColors(provider, account)
|
||||
else
|
||||
AndroidCalendar.removeColors(provider, account)
|
||||
|
||||
updateLocalCalendars(provider, account, accountSettings)
|
||||
|
||||
val calendars = AndroidCalendar
|
||||
.find(account, provider, LocalCalendar.Factory, "${CalendarContract.Calendars.SYNC_EVENTS}!=0", null)
|
||||
for (calendar in calendars) {
|
||||
Logger.log.info("Synchronizing calendar #${calendar.id}, URL: ${calendar.name}")
|
||||
CalendarSyncManager(context, account, accountSettings, extras, httpClient.value, authority, syncResult, calendar).performSync()
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't sync calendars", e)
|
||||
}
|
||||
Logger.log.info("Calendar sync complete")
|
||||
}
|
||||
|
||||
private fun updateLocalCalendars(provider: ContentProviderClient, account: Account, settings: AccountSettings) {
|
||||
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV)
|
||||
|
||||
val remoteCalendars = mutableMapOf<HttpUrl, Collection>()
|
||||
if (service != null)
|
||||
for (collection in db.collectionDao().getSyncCalendars(service.id)) {
|
||||
remoteCalendars[collection.url] = collection
|
||||
}
|
||||
|
||||
// delete/update local calendars
|
||||
val updateColors = settings.getManageCalendarColors()
|
||||
for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null))
|
||||
calendar.name?.let {
|
||||
val url = it.toHttpUrl()
|
||||
val info = remoteCalendars[url]
|
||||
if (info == null) {
|
||||
Logger.log.log(Level.INFO, "Deleting obsolete local calendar", url)
|
||||
calendar.delete()
|
||||
} else {
|
||||
// remote CollectionInfo found for this local collection, update data
|
||||
Logger.log.log(Level.FINE, "Updating local calendar $url", info)
|
||||
calendar.update(info, updateColors)
|
||||
// we already have a local calendar for this remote collection, don't take into consideration anymore
|
||||
remoteCalendars -= url
|
||||
}
|
||||
}
|
||||
|
||||
// create new local calendars
|
||||
for ((_, info) in remoteCalendars) {
|
||||
Logger.log.log(Level.INFO, "Adding local calendar", info)
|
||||
LocalCalendar.create(account, provider, info)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,431 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Build
|
||||
import at.bitfire.dav4jvm.DavAddressBook
|
||||
import at.bitfire.dav4jvm.MultiResponseCallback
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.property.caldav.GetCTag
|
||||
import at.bitfire.dav4jvm.property.carddav.AddressData
|
||||
import at.bitfire.dav4jvm.property.carddav.MaxResourceSize
|
||||
import at.bitfire.dav4jvm.property.carddav.SupportedAddressData
|
||||
import at.bitfire.dav4jvm.property.webdav.GetContentType
|
||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.dav4jvm.property.webdav.SupportedReportSet
|
||||
import at.bitfire.dav4jvm.property.webdav.SyncToken
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.resource.LocalAddress
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalContact
|
||||
import at.bitfire.davdroid.resource.LocalGroup
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.syncadapter.groups.CategoriesStrategy
|
||||
import at.bitfire.davdroid.syncadapter.groups.VCard4Strategy
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import at.bitfire.davdroid.util.DavUtils.sameTypeAs
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import ezvcard.VCardVersion
|
||||
import ezvcard.io.CannotParseException
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.apache.commons.io.FileUtils
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.Reader
|
||||
import java.io.StringReader
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Synchronization manager for CardDAV collections; handles contacts and groups.
|
||||
*
|
||||
* Group handling differs according to the {@link #groupMethod}. There are two basic methods to
|
||||
* handle/manage groups:
|
||||
*
|
||||
* 1. CATEGORIES: groups memberships are attached to each contact and represented as
|
||||
* "category". When a group is dirty or has been deleted, all its members have to be set to
|
||||
* dirty, too (because they have to be uploaded without the respective category). This
|
||||
* is done in [uploadDirty]. Empty groups can be deleted without further processing,
|
||||
* which is done in [postProcess] because groups may become empty after downloading
|
||||
* updated remote contacts.
|
||||
*
|
||||
* 2. Groups as separate VCards: individual and group contacts (with a list of member UIDs) are
|
||||
* distinguished. When a local group is dirty, its members don't need to be set to dirty.
|
||||
*
|
||||
* However, when a contact is dirty, it has
|
||||
* to be checked whether its group memberships have changed. In this case, the respective
|
||||
* groups have to be set to dirty. For instance, if contact A is in group G and H, and then
|
||||
* group membership of G is removed, the contact will be set to dirty because of the changed
|
||||
* [android.provider.ContactsContract.CommonDataKinds.GroupMembership]. DAVx5 will
|
||||
* then have to check whether the group memberships have actually changed, and if so,
|
||||
* all affected groups have to be set to dirty. To detect changes in group memberships,
|
||||
* DAVx5 always mirrors all [android.provider.ContactsContract.CommonDataKinds.GroupMembership]
|
||||
* data rows in respective [at.bitfire.vcard4android.CachedGroupMembership] rows.
|
||||
* If the cached group memberships are not the same as the current group member ships, the
|
||||
* difference set (in our example G, because its in the cached memberships, but not in the
|
||||
* actual ones) is marked as dirty. This is done in [uploadDirty].
|
||||
*
|
||||
* When downloading remote contacts, groups (+ member information) may be received
|
||||
* by the actual members. Thus, the member lists have to be cached until all VCards
|
||||
* are received. This is done by caching the member UIDs of each group in
|
||||
* [LocalGroup.COLUMN_PENDING_MEMBERS]. In [postProcess],
|
||||
* these "pending memberships" are assigned to the actual contacts and then cleaned up.
|
||||
*/
|
||||
class ContactsSyncManager(
|
||||
context: Context,
|
||||
account: Account,
|
||||
accountSettings: AccountSettings,
|
||||
httpClient: HttpClient,
|
||||
extras: Array<String>,
|
||||
authority: String,
|
||||
syncResult: SyncResult,
|
||||
val provider: ContentProviderClient,
|
||||
localAddressBook: LocalAddressBook
|
||||
): SyncManager<LocalAddress, LocalAddressBook, DavAddressBook>(context, account, accountSettings, httpClient, extras, authority, syncResult, localAddressBook) {
|
||||
|
||||
companion object {
|
||||
infix fun <T> Set<T>.disjunct(other: Set<T>) = (this - other) union (other - this)
|
||||
}
|
||||
|
||||
private var hasVCard4 = false
|
||||
private var hasJCard = false
|
||||
private val groupStrategy = when (accountSettings.getGroupMethod()) {
|
||||
GroupMethod.GROUP_VCARDS -> VCard4Strategy(localAddressBook)
|
||||
GroupMethod.CATEGORIES -> CategoriesStrategy(localAddressBook)
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to download images which are referenced by URL
|
||||
*/
|
||||
private lateinit var resourceDownloader: ResourceDownloader
|
||||
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
val reallyDirty = localCollection.verifyDirty()
|
||||
val deleted = localCollection.findDeleted().size
|
||||
if (extras.contains(ContentResolver.SYNC_EXTRAS_UPLOAD) && reallyDirty == 0 && deleted == 0) {
|
||||
Logger.log.info("This sync was called to up-sync dirty/deleted contacts, but no contacts have been changed")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
collectionURL = localCollection.url.toHttpUrlOrNull() ?: return false
|
||||
davCollection = DavAddressBook(httpClient.okHttpClient, collectionURL)
|
||||
|
||||
resourceDownloader = ResourceDownloader(davCollection.location)
|
||||
|
||||
Logger.log.info("Contact group strategy: ${groupStrategy::class.java.simpleName}")
|
||||
return true
|
||||
}
|
||||
|
||||
override fun queryCapabilities(): SyncState? {
|
||||
return remoteExceptionContext {
|
||||
var syncState: SyncState? = null
|
||||
it.propfind(0, MaxResourceSize.NAME, SupportedAddressData.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF) {
|
||||
response[MaxResourceSize::class.java]?.maxSize?.let { maxSize ->
|
||||
Logger.log.info("Address book accepts vCards up to ${FileUtils.byteCountToDisplaySize(maxSize)}")
|
||||
}
|
||||
|
||||
response[SupportedAddressData::class.java]?.let { supported ->
|
||||
hasVCard4 = supported.hasVCard4()
|
||||
|
||||
// temporarily disable jCard because of https://github.com/nextcloud/server/issues/29693
|
||||
// hasJCard = supported.hasJCard()
|
||||
}
|
||||
response[SupportedReportSet::class.java]?.let { supported ->
|
||||
hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION)
|
||||
}
|
||||
syncState = syncState(response)
|
||||
}
|
||||
}
|
||||
|
||||
// Logger.log.info("Server supports jCard: $hasJCard")
|
||||
Logger.log.info("Address book supports vCard4: $hasVCard4")
|
||||
Logger.log.info("Address book supports Collection Sync: $hasCollectionSync")
|
||||
|
||||
syncState
|
||||
}
|
||||
}
|
||||
|
||||
override fun syncAlgorithm() =
|
||||
if (hasCollectionSync)
|
||||
SyncAlgorithm.COLLECTION_SYNC
|
||||
else
|
||||
SyncAlgorithm.PROPFIND_REPORT
|
||||
|
||||
override fun processLocallyDeleted() =
|
||||
if (localCollection.readOnly) {
|
||||
var modified = false
|
||||
for (group in localCollection.findDeletedGroups()) {
|
||||
Logger.log.warning("Restoring locally deleted group (read-only address book!)")
|
||||
localExceptionContext(group) { it.resetDeleted() }
|
||||
modified = true
|
||||
}
|
||||
|
||||
for (contact in localCollection.findDeletedContacts()) {
|
||||
Logger.log.warning("Restoring locally deleted contact (read-only address book!)")
|
||||
localExceptionContext(contact) { it.resetDeleted() }
|
||||
modified = true
|
||||
}
|
||||
|
||||
/* This is unfortunately dirty: When a contact has been inserted to a read-only address book
|
||||
that supports Collection Sync, it's not enough to force synchronization (by returning true),
|
||||
but we also need to make sure all contacts are downloaded again. */
|
||||
if (modified)
|
||||
localCollection.lastSyncState = null
|
||||
|
||||
modified
|
||||
} else
|
||||
// mirror deletions to remote collection (DELETE)
|
||||
super.processLocallyDeleted()
|
||||
|
||||
override fun uploadDirty(): Boolean {
|
||||
var modified = false
|
||||
|
||||
if (localCollection.readOnly) {
|
||||
for (group in localCollection.findDirtyGroups()) {
|
||||
Logger.log.warning("Resetting locally modified group to ETag=null (read-only address book!)")
|
||||
localExceptionContext(group) { it.clearDirty(null, null) }
|
||||
modified = true
|
||||
}
|
||||
|
||||
for (contact in localCollection.findDirtyContacts()) {
|
||||
Logger.log.warning("Resetting locally modified contact to ETag=null (read-only address book!)")
|
||||
localExceptionContext(contact) { it.clearDirty(null, null) }
|
||||
modified = true
|
||||
}
|
||||
|
||||
// see same position in processLocallyDeleted
|
||||
if (modified)
|
||||
localCollection.lastSyncState = null
|
||||
|
||||
} else
|
||||
// we only need to handle changes in groups when the address book is read/write
|
||||
groupStrategy.beforeUploadDirty()
|
||||
|
||||
// generate UID/file name for newly created contacts
|
||||
val superModified = super.uploadDirty()
|
||||
|
||||
// return true when any operation returned true
|
||||
return modified or superModified
|
||||
}
|
||||
|
||||
override fun generateUpload(resource: LocalAddress): RequestBody =
|
||||
localExceptionContext(resource) {
|
||||
val contact: Contact = when (resource) {
|
||||
is LocalContact -> resource.getContact()
|
||||
is LocalGroup -> resource.getContact()
|
||||
else -> throw IllegalArgumentException("resource must be LocalContact or LocalGroup")
|
||||
}
|
||||
|
||||
Logger.log.log(Level.FINE, "Preparing upload of vCard ${resource.fileName}", contact)
|
||||
|
||||
val os = ByteArrayOutputStream()
|
||||
val mimeType: MediaType
|
||||
when {
|
||||
hasJCard -> {
|
||||
mimeType = DavAddressBook.MIME_JCARD
|
||||
contact.writeJCard(os)
|
||||
}
|
||||
hasVCard4 -> {
|
||||
mimeType = DavAddressBook.MIME_VCARD4
|
||||
contact.writeVCard(VCardVersion.V4_0, os)
|
||||
}
|
||||
else -> {
|
||||
mimeType = DavAddressBook.MIME_VCARD3_UTF8
|
||||
contact.writeVCard(VCardVersion.V3_0, os)
|
||||
}
|
||||
}
|
||||
|
||||
return@localExceptionContext(os.toByteArray().toRequestBody(mimeType))
|
||||
}
|
||||
|
||||
override fun listAllRemote(callback: MultiResponseCallback) =
|
||||
remoteExceptionContext {
|
||||
it.propfind(1, ResourceType.NAME, GetETag.NAME, callback = callback)
|
||||
}
|
||||
|
||||
override fun downloadRemote(bunch: List<HttpUrl>) {
|
||||
Logger.log.info("Downloading ${bunch.size} vCard(s): $bunch")
|
||||
remoteExceptionContext {
|
||||
val contentType: String?
|
||||
val version: String?
|
||||
when {
|
||||
hasJCard -> {
|
||||
contentType = DavUtils.MEDIA_TYPE_JCARD.toString()
|
||||
version = VCardVersion.V4_0.version
|
||||
}
|
||||
hasVCard4 -> {
|
||||
contentType = DavUtils.MEDIA_TYPE_VCARD.toString()
|
||||
version = VCardVersion.V4_0.version
|
||||
}
|
||||
else -> {
|
||||
contentType = DavUtils.MEDIA_TYPE_VCARD.toString()
|
||||
version = null // 3.0 is the default version; don't request 3.0 explicitly because maybe some vCard3-only servers don't understand it
|
||||
}
|
||||
}
|
||||
it.multiget(bunch, contentType, version) { response, _ ->
|
||||
responseExceptionContext(response) {
|
||||
if (!response.isSuccess()) {
|
||||
Logger.log.warning("Received non-successful multiget response for ${response.href}")
|
||||
return@responseExceptionContext
|
||||
}
|
||||
|
||||
val eTag = response[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
|
||||
var isJCard = hasJCard // assume that server has sent what we have requested (we ask for jCard only when the server advertises it)
|
||||
response[GetContentType::class.java]?.type?.let { type ->
|
||||
isJCard = type.sameTypeAs(DavUtils.MEDIA_TYPE_JCARD)
|
||||
}
|
||||
|
||||
val addressData = response[AddressData::class.java]
|
||||
val card = addressData?.card
|
||||
?: throw DavException("Received multi-get response without address data")
|
||||
|
||||
processCard(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(card), isJCard, resourceDownloader)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun postProcess() {
|
||||
groupStrategy.postProcess()
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun processCard(fileName: String, eTag: String, reader: Reader, jCard: Boolean, downloader: Contact.Downloader) {
|
||||
Logger.log.info("Processing CardDAV resource $fileName")
|
||||
|
||||
val contacts = try {
|
||||
Contact.fromReader(reader, jCard, downloader)
|
||||
} catch (e: CannotParseException) {
|
||||
Logger.log.log(Level.SEVERE, "Received invalid vCard, ignoring", e)
|
||||
notifyInvalidResource(e, fileName)
|
||||
return
|
||||
}
|
||||
|
||||
if (contacts.isEmpty()) {
|
||||
Logger.log.warning("Received vCard without data, ignoring")
|
||||
return
|
||||
} else if (contacts.size > 1)
|
||||
Logger.log.warning("Received multiple vCards, using first one")
|
||||
|
||||
val newData = contacts.first()
|
||||
groupStrategy.verifyContactBeforeSaving(newData)
|
||||
|
||||
// update local contact, if it exists
|
||||
localExceptionContext(localCollection.findByName(fileName)) {
|
||||
var local = it
|
||||
if (local != null) {
|
||||
Logger.log.log(Level.INFO, "Updating $fileName in local address book", newData)
|
||||
|
||||
if (local is LocalGroup && newData.group) {
|
||||
// update group
|
||||
local.eTag = eTag
|
||||
local.flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
local.update(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
|
||||
} else if (local is LocalContact && !newData.group) {
|
||||
// update contact
|
||||
local.eTag = eTag
|
||||
local.flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
local.update(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
|
||||
} else {
|
||||
// group has become an individual contact or vice versa, delete and create with new type
|
||||
local.delete()
|
||||
local = null
|
||||
}
|
||||
}
|
||||
|
||||
if (local == null) {
|
||||
if (newData.group) {
|
||||
Logger.log.log(Level.INFO, "Creating local group", newData)
|
||||
localExceptionContext(LocalGroup(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { group ->
|
||||
group.add()
|
||||
local = group
|
||||
}
|
||||
} else {
|
||||
Logger.log.log(Level.INFO, "Creating local contact", newData)
|
||||
localExceptionContext(LocalContact(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { contact ->
|
||||
contact.add()
|
||||
local = contact
|
||||
}
|
||||
}
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
(local as? LocalContact)?.updateHashCode(null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// downloader helper class
|
||||
|
||||
private inner class ResourceDownloader(
|
||||
val baseUrl: HttpUrl
|
||||
): Contact.Downloader {
|
||||
|
||||
override fun download(url: String, accepts: String): ByteArray? {
|
||||
val httpUrl = url.toHttpUrlOrNull()
|
||||
if (httpUrl == null) {
|
||||
Logger.log.log(Level.SEVERE, "Invalid external resource URL", url)
|
||||
return null
|
||||
}
|
||||
|
||||
// authenticate only against a certain host, and only upon request
|
||||
val client = HttpClient.Builder(context, baseUrl.host, accountSettings.credentials())
|
||||
.followRedirects(true) // allow redirects
|
||||
.build()
|
||||
|
||||
try {
|
||||
val response = client.okHttpClient.newCall(Request.Builder()
|
||||
.get()
|
||||
.url(httpUrl)
|
||||
.build()).execute()
|
||||
|
||||
if (response.isSuccessful)
|
||||
return response.body?.bytes()
|
||||
else
|
||||
Logger.log.warning("Couldn't download external resource")
|
||||
} catch(e: IOException) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't download external resource", e)
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
override fun notifyInvalidResourceTitle(): String =
|
||||
context.getString(R.string.sync_invalid_contact)
|
||||
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import at.bitfire.dav4jvm.DavCalendar
|
||||
import at.bitfire.dav4jvm.MultiResponseCallback
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.property.caldav.CalendarData
|
||||
import at.bitfire.dav4jvm.property.caldav.GetCTag
|
||||
import at.bitfire.dav4jvm.property.caldav.MaxResourceSize
|
||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||
import at.bitfire.dav4jvm.property.webdav.SyncToken
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.resource.LocalJtxCollection
|
||||
import at.bitfire.davdroid.resource.LocalJtxICalObject
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import at.bitfire.ical4android.InvalidCalendarException
|
||||
import at.bitfire.ical4android.JtxICalObject
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.apache.commons.io.FileUtils
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.Reader
|
||||
import java.io.StringReader
|
||||
import java.util.logging.Level
|
||||
|
||||
class JtxSyncManager(
|
||||
context: Context,
|
||||
account: Account,
|
||||
accountSettings: AccountSettings,
|
||||
extras: Array<String>,
|
||||
httpClient: HttpClient,
|
||||
authority: String,
|
||||
syncResult: SyncResult,
|
||||
localCollection: LocalJtxCollection
|
||||
): SyncManager<LocalJtxICalObject, LocalJtxCollection, DavCalendar>(context, account, accountSettings, httpClient, extras, authority, syncResult, localCollection) {
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
collectionURL = (localCollection.url ?: return false).toHttpUrlOrNull() ?: return false
|
||||
davCollection = DavCalendar(httpClient.okHttpClient, collectionURL)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun queryCapabilities() =
|
||||
remoteExceptionContext {
|
||||
var syncState: SyncState? = null
|
||||
it.propfind(0, GetCTag.NAME, MaxResourceSize.NAME, SyncToken.NAME) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF) {
|
||||
response[MaxResourceSize::class.java]?.maxSize?.let { maxSize ->
|
||||
Logger.log.info("Collection accepts resources up to ${FileUtils.byteCountToDisplaySize(maxSize)}")
|
||||
}
|
||||
|
||||
syncState = syncState(response)
|
||||
}
|
||||
}
|
||||
syncState
|
||||
}
|
||||
|
||||
override fun generateUpload(resource: LocalJtxICalObject): RequestBody = localExceptionContext(resource) {
|
||||
Logger.log.log(Level.FINE, "Preparing upload of icalobject ${resource.fileName}", resource)
|
||||
val os = ByteArrayOutputStream()
|
||||
resource.write(os)
|
||||
os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
|
||||
}
|
||||
|
||||
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
|
||||
|
||||
override fun listAllRemote(callback: MultiResponseCallback) {
|
||||
remoteExceptionContext { remote ->
|
||||
if (localCollection.supportsVTODO) {
|
||||
Logger.log.info("Querying tasks")
|
||||
remote.calendarQuery("VTODO", null, null, callback)
|
||||
}
|
||||
|
||||
if (localCollection.supportsVJOURNAL) {
|
||||
Logger.log.info("Querying journals")
|
||||
remote.calendarQuery("VJOURNAL", null, null, callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun downloadRemote(bunch: List<HttpUrl>) {
|
||||
Logger.log.info("Downloading ${bunch.size} iCalendars: $bunch")
|
||||
// multiple iCalendars, use calendar-multi-get
|
||||
remoteExceptionContext {
|
||||
it.multiget(bunch) { response, _ ->
|
||||
responseExceptionContext(response) {
|
||||
if (!response.isSuccess()) {
|
||||
Logger.log.warning("Received non-successful multiget response for ${response.href}")
|
||||
return@responseExceptionContext
|
||||
}
|
||||
|
||||
val eTag = response[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
|
||||
val calendarData = response[CalendarData::class.java]
|
||||
val iCal = calendarData?.iCalendar
|
||||
?: throw DavException("Received multi-get response without task data")
|
||||
|
||||
processICalObject(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(iCal))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun postProcess() {
|
||||
localCollection.updateLastSync()
|
||||
}
|
||||
|
||||
override fun notifyInvalidResourceTitle(): String =
|
||||
context.getString(R.string.sync_invalid_event)
|
||||
|
||||
|
||||
private fun processICalObject(fileName: String, eTag: String, reader: Reader) {
|
||||
val icalobjects: MutableList<JtxICalObject> = mutableListOf()
|
||||
try {
|
||||
// parse the reader content and return the list of ICalObjects
|
||||
icalobjects.addAll(JtxICalObject.fromReader(reader, localCollection))
|
||||
} catch (e: InvalidCalendarException) {
|
||||
Logger.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e)
|
||||
notifyInvalidResource(e, fileName)
|
||||
return
|
||||
}
|
||||
|
||||
Logger.log.log(Level.INFO, "Found ${icalobjects.size} entries in $fileName", icalobjects)
|
||||
|
||||
icalobjects.forEach { jtxICalObject ->
|
||||
// if the entry is a recurring entry (and therefore has a recurid)
|
||||
// we udpate the existing (generated) entry
|
||||
if(jtxICalObject.recurid != null) {
|
||||
localExceptionContext(localCollection.findRecurring(jtxICalObject.uid, jtxICalObject.recurid!!, jtxICalObject.dtstart!!)) { local ->
|
||||
Logger.log.log(Level.INFO, "Updating $fileName with recur instance ${jtxICalObject.recurid} in local list", jtxICalObject)
|
||||
if(local != null) {
|
||||
local.update(jtxICalObject)
|
||||
syncResult.stats.numUpdates++
|
||||
} else {
|
||||
localExceptionContext(LocalJtxICalObject(localCollection, fileName, eTag, null, LocalResource.FLAG_REMOTELY_PRESENT)) {
|
||||
it.applyNewData(jtxICalObject)
|
||||
it.add()
|
||||
}
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// otherwise we insert or update the main entry
|
||||
localExceptionContext(localCollection.findByName(fileName)) { local ->
|
||||
if (local != null) {
|
||||
Logger.log.log(Level.INFO, "Updating $fileName in local list", jtxICalObject)
|
||||
local.eTag = eTag
|
||||
local.update(jtxICalObject)
|
||||
syncResult.stats.numUpdates++
|
||||
} else {
|
||||
Logger.log.log(Level.INFO, "Adding $fileName to local list", jtxICalObject)
|
||||
|
||||
localExceptionContext(LocalJtxICalObject(localCollection, fileName, eTag, null, LocalResource.FLAG_REMOTELY_PRESENT)) {
|
||||
it.applyNewData(jtxICalObject)
|
||||
it.add()
|
||||
}
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Build
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.resource.LocalJtxCollection
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.util.TaskUtils
|
||||
import at.bitfire.ical4android.JtxCollection
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Sync logic for jtx board
|
||||
*/
|
||||
class JtxSyncer(context: Context): Syncer(context) {
|
||||
|
||||
override fun sync(
|
||||
account: Account,
|
||||
extras: Array<String>,
|
||||
authority: String,
|
||||
httpClient: Lazy<HttpClient>,
|
||||
provider: ContentProviderClient,
|
||||
syncResult: SyncResult
|
||||
) {
|
||||
try {
|
||||
// check whether jtx Board is new enough
|
||||
TaskProvider.checkVersion(context, TaskProvider.ProviderName.JtxBoard)
|
||||
|
||||
// make sure account can be seen by task provider
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
/* Warning: If setAccountVisibility is called, Android 12 broadcasts the
|
||||
AccountManager.LOGIN_ACCOUNTS_CHANGED_ACTION Intent. This cancels running syncs
|
||||
and starts them again! So make sure setAccountVisibility is only called when necessary. */
|
||||
val am = AccountManager.get(context)
|
||||
if (am.getAccountVisibility(account, TaskProvider.ProviderName.JtxBoard.packageName) != AccountManager.VISIBILITY_VISIBLE)
|
||||
am.setAccountVisibility(account, TaskProvider.ProviderName.JtxBoard.packageName, AccountManager.VISIBILITY_VISIBLE)
|
||||
}
|
||||
|
||||
val accountSettings = AccountSettings(context, account)
|
||||
|
||||
// sync list of collections
|
||||
updateLocalCollections(account, provider, accountSettings)
|
||||
|
||||
// sync contents of collections
|
||||
val collections = JtxCollection.find(account, provider, context, LocalJtxCollection.Factory, null, null)
|
||||
for (collection in collections) {
|
||||
Logger.log.info("Synchronizing $collection")
|
||||
JtxSyncManager(context, account, accountSettings, extras, httpClient.value, authority, syncResult, collection).performSync()
|
||||
}
|
||||
|
||||
} catch (e: TaskProvider.ProviderTooOldException) {
|
||||
TaskUtils.notifyProviderTooOld(context, e)
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't sync jtx collections", e)
|
||||
}
|
||||
Logger.log.info("jtx sync complete")
|
||||
}
|
||||
|
||||
private fun updateLocalCollections(account: Account, client: ContentProviderClient, settings: AccountSettings) {
|
||||
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV)
|
||||
|
||||
val remoteCollections = mutableMapOf<HttpUrl, Collection>()
|
||||
if (service != null)
|
||||
for (collection in db.collectionDao().getSyncJtxCollections(service.id))
|
||||
remoteCollections[collection.url] = collection
|
||||
|
||||
val updateColors = settings.getManageCalendarColors()
|
||||
|
||||
for (jtxCollection in JtxCollection.find(account, client, context, LocalJtxCollection.Factory, null, null))
|
||||
jtxCollection.url?.let { strUrl ->
|
||||
val url = strUrl.toHttpUrl()
|
||||
val info = remoteCollections[url]
|
||||
if (info == null) {
|
||||
Logger.log.fine("Deleting obsolete local collection $url")
|
||||
jtxCollection.delete()
|
||||
} else {
|
||||
// remote CollectionInfo found for this local collection, update data
|
||||
Logger.log.log(Level.FINE, "Updating local collection $url", info)
|
||||
val owner = info.ownerId?.let { db.principalDao().get(it) }
|
||||
jtxCollection.updateCollection(info, owner, updateColors)
|
||||
// we already have a local task list for this remote collection, don't take into consideration anymore
|
||||
remoteCollections -= url
|
||||
}
|
||||
}
|
||||
|
||||
// create new local collections
|
||||
for ((_,info) in remoteCollections) {
|
||||
Logger.log.log(Level.INFO, "Adding local collections", info)
|
||||
val owner = info.ownerId?.let { db.principalDao().get(it) }
|
||||
LocalJtxCollection.create(account, client, info, owner)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import androidx.annotation.IntDef
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkRequest
|
||||
import androidx.work.WorkerParameters
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* One-time sync worker.
|
||||
*
|
||||
* Expedited: yes
|
||||
*
|
||||
* Long-running: no
|
||||
*/
|
||||
@HiltWorker
|
||||
class OneTimeSyncWorker @AssistedInject constructor(
|
||||
@Assisted appContext: Context,
|
||||
@Assisted workerParams: WorkerParameters
|
||||
) : BaseSyncWorker(appContext, workerParams) {
|
||||
|
||||
companion object {
|
||||
|
||||
const val ARG_UPLOAD = "upload"
|
||||
|
||||
const val ARG_RESYNC = "resync"
|
||||
@IntDef(NO_RESYNC, RESYNC, FULL_RESYNC)
|
||||
annotation class ArgResync
|
||||
const val NO_RESYNC = 0
|
||||
const val RESYNC = 1
|
||||
const val FULL_RESYNC = 2
|
||||
|
||||
/**
|
||||
* Unique work name of this worker. Can also be used as tag.
|
||||
*
|
||||
* Mainly used to query [WorkManager] for work state (by unique work name or tag).
|
||||
*
|
||||
* @param account the account this worker is running for
|
||||
* @param authority the authority this worker is running for
|
||||
* @return Name of this worker composed as "onetime-sync $authority ${account.type}/${account.name}"
|
||||
*/
|
||||
fun workerName(account: Account, authority: String): String =
|
||||
"onetime-sync $authority ${account.type}/${account.name}"
|
||||
|
||||
/**
|
||||
* Requests immediate synchronization of an account with all applicable
|
||||
* authorities (contacts, calendars, …).
|
||||
*
|
||||
* @see enqueue
|
||||
*/
|
||||
fun enqueueAllAuthorities(
|
||||
context: Context,
|
||||
account: Account,
|
||||
manual: Boolean = false,
|
||||
@ArgResync resync: Int = NO_RESYNC,
|
||||
upload: Boolean = false
|
||||
) {
|
||||
for (authority in SyncUtils.syncAuthorities(context))
|
||||
enqueue(context, account, authority, manual = manual, resync = resync, upload = upload)
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests immediate synchronization of an account with a specific authority.
|
||||
*
|
||||
* @param account account to sync
|
||||
* @param authority authority to sync (for instance: [CalendarContract.AUTHORITY])
|
||||
* @param manual user-initiated sync (ignores network checks)
|
||||
* @param resync whether to request (full) re-synchronization or not
|
||||
* @param upload see [ContentResolver.SYNC_EXTRAS_UPLOAD] used only for contacts sync
|
||||
* and android 7 workaround
|
||||
* @return existing or newly created worker name
|
||||
*/
|
||||
fun enqueue(
|
||||
context: Context,
|
||||
account: Account,
|
||||
authority: String,
|
||||
manual: Boolean = false,
|
||||
@ArgResync resync: Int = NO_RESYNC,
|
||||
upload: Boolean = false
|
||||
): String {
|
||||
// Worker arguments
|
||||
val argumentsBuilder = Data.Builder()
|
||||
.putString(INPUT_AUTHORITY, authority)
|
||||
.putString(INPUT_ACCOUNT_NAME, account.name)
|
||||
.putString(INPUT_ACCOUNT_TYPE, account.type)
|
||||
if (manual)
|
||||
argumentsBuilder.putBoolean(INPUT_MANUAL, true)
|
||||
if (resync != NO_RESYNC)
|
||||
argumentsBuilder.putInt(ARG_RESYNC, resync)
|
||||
argumentsBuilder.putBoolean(ARG_UPLOAD, upload)
|
||||
|
||||
// build work request
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED) // require a network connection
|
||||
.build()
|
||||
val workRequest = OneTimeWorkRequestBuilder<OneTimeSyncWorker>()
|
||||
.addTag(workerName(account, authority))
|
||||
.addTag(commonTag(account, authority))
|
||||
.setInputData(argumentsBuilder.build())
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
WorkRequest.DEFAULT_BACKOFF_DELAY_MILLIS, // 30 sec
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
.setConstraints(constraints)
|
||||
|
||||
/* OneTimeSyncWorker is started by user or sync framework when there are local changes.
|
||||
In both cases, synchronization should be done as soon as possible, so we set expedited. */
|
||||
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
|
||||
// enqueue and start syncing
|
||||
val name = workerName(account, authority)
|
||||
val request = workRequest.build()
|
||||
Logger.log.log(Level.INFO, "Enqueueing unique worker: $name, tags = ${request.tags}")
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
name,
|
||||
/* If sync is already running, just continue.
|
||||
Existing retried work will not be replaced (for instance when
|
||||
PeriodicSyncWorker enqueues another scheduled sync). */
|
||||
ExistingWorkPolicy.KEEP,
|
||||
request
|
||||
)
|
||||
return name
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Used by WorkManager to show a foreground service notification for expedited jobs on Android <12.
|
||||
*/
|
||||
override suspend fun getForegroundInfo(): ForegroundInfo {
|
||||
val notification = NotificationUtils.newBuilder(applicationContext, NotificationUtils.CHANNEL_STATUS)
|
||||
.setSmallIcon(R.drawable.ic_foreground_notify)
|
||||
.setContentTitle(applicationContext.getString(R.string.foreground_service_notify_title))
|
||||
.setContentText(applicationContext.getString(R.string.foreground_service_notify_text))
|
||||
.setStyle(NotificationCompat.BigTextStyle())
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
|
||||
.build()
|
||||
return ForegroundInfo(NotificationUtils.NOTIFY_SYNC_EXPEDITED, notification)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.Operation
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
|
||||
/**
|
||||
* Handles scheduled sync requests.
|
||||
*
|
||||
* The different periodic sync workers each carry a unique work name composed of the account and
|
||||
* authority which they are responsible for. For each account there will be multiple dedicated periodic
|
||||
* sync workers for each authority. See [PeriodicSyncWorker.workerName] for more information.
|
||||
*
|
||||
* Deferrable: yes (periodic)
|
||||
*
|
||||
* Expedited: no (→ no [getForegroundInfo])
|
||||
*
|
||||
* Long-running: no
|
||||
*/
|
||||
@HiltWorker
|
||||
class PeriodicSyncWorker @AssistedInject constructor(
|
||||
@Assisted appContext: Context,
|
||||
@Assisted workerParams: WorkerParameters
|
||||
) : BaseSyncWorker(appContext, workerParams) {
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Unique work name of this worker. Can also be used as tag.
|
||||
*
|
||||
* Mainly used to query [WorkManager] for work state (by unique work name or tag).
|
||||
*
|
||||
* @param account the account this worker is running for
|
||||
* @param authority the authority this worker is running for
|
||||
* @return Name of this worker composed as "periodic-sync $authority ${account.type}/${account.name}"
|
||||
*/
|
||||
fun workerName(account: Account, authority: String): String =
|
||||
"periodic-sync $authority ${account.type}/${account.name}"
|
||||
|
||||
/**
|
||||
* Activate scheduled synchronization of an account with a specific authority.
|
||||
*
|
||||
* @param account account to sync
|
||||
* @param authority authority to sync (for instance: [CalendarContract.AUTHORITY]])
|
||||
* @param interval interval between recurring syncs in seconds
|
||||
* @return operation object to check when and whether activation was successful
|
||||
*/
|
||||
fun enable(context: Context, account: Account, authority: String, interval: Long, syncWifiOnly: Boolean): Operation {
|
||||
val arguments = Data.Builder()
|
||||
.putString(INPUT_AUTHORITY, authority)
|
||||
.putString(INPUT_ACCOUNT_NAME, account.name)
|
||||
.putString(INPUT_ACCOUNT_TYPE, account.type)
|
||||
.build()
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(
|
||||
if (syncWifiOnly)
|
||||
NetworkType.UNMETERED
|
||||
else
|
||||
NetworkType.CONNECTED
|
||||
).build()
|
||||
val workRequest = PeriodicWorkRequestBuilder<PeriodicSyncWorker>(interval, TimeUnit.SECONDS)
|
||||
.addTag(workerName(account, authority))
|
||||
.addTag(commonTag(account, authority))
|
||||
.setInputData(arguments)
|
||||
.setConstraints(constraints)
|
||||
.build()
|
||||
return WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
workerName(account, authority),
|
||||
// if a periodic sync exists already, we want to update it with the new interval
|
||||
// and/or new required network type (applies on next iteration of periodic worker)
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
workRequest
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables scheduled synchronization of an account for a specific authority.
|
||||
*
|
||||
* @param account account to sync
|
||||
* @param authority authority to sync (for instance: [CalendarContract.AUTHORITY]])
|
||||
* @return operation object to check process state of work cancellation
|
||||
*/
|
||||
fun disable(context: Context, account: Account, authority: String): Operation =
|
||||
WorkManager.getInstance(context)
|
||||
.cancelUniqueWork(workerName(account, authority))
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
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.provider.ContactsContract
|
||||
import androidx.work.WorkManager
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.logging.Level
|
||||
|
||||
abstract class SyncAdapterService: Service() {
|
||||
|
||||
fun syncAdapter() = SyncAdapter(this)
|
||||
|
||||
override fun onBind(intent: Intent?) = syncAdapter().syncAdapterBinder!!
|
||||
|
||||
/**
|
||||
* Entry point for the sync adapter framework.
|
||||
*
|
||||
* Handles incoming sync requests from the sync adapter framework.
|
||||
*
|
||||
* Although we do not use the sync adapter for syncing anymore, we keep this sole
|
||||
* adapter to provide exported services, which allow android system components and calendar,
|
||||
* contacts or task apps to sync via DAVx5.
|
||||
*/
|
||||
class SyncAdapter(
|
||||
context: Context
|
||||
): AbstractThreadedSyncAdapter(
|
||||
context,
|
||||
true // isSyncable shouldn't be -1 because DAVx5 sets it to 0 or 1.
|
||||
// However, if it is -1 by accident, set it to 1 to avoid endless sync loops.
|
||||
) {
|
||||
|
||||
/**
|
||||
* Completable [Boolean], which will be set to
|
||||
*
|
||||
* - `true` when the related sync worker has finished
|
||||
* - `false` when the sync framework has requested cancellation.
|
||||
*
|
||||
* In any case, the sync framework shouldn't be blocked anymore as soon as a
|
||||
* value is available.
|
||||
*/
|
||||
val finished = CompletableDeferred<Boolean>()
|
||||
|
||||
override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
// We seem to have to pass this old SyncFramework extra for an Android 7 workaround
|
||||
val upload = extras.containsKey(ContentResolver.SYNC_EXTRAS_UPLOAD)
|
||||
Logger.log.info("Sync request via sync framework for $account $authority (upload=$upload)")
|
||||
|
||||
val accountSettings = try {
|
||||
AccountSettings(context, account)
|
||||
} catch (e: InvalidAccountException) {
|
||||
Logger.log.log(Level.WARNING, "Account doesn't exist anymore", e)
|
||||
return
|
||||
}
|
||||
|
||||
// Should we run the sync at all?
|
||||
if (!BaseSyncWorker.wifiConditionsMet(context, accountSettings)) {
|
||||
Logger.log.info("Sync conditions not met. Aborting sync framework initiated sync")
|
||||
return
|
||||
}
|
||||
|
||||
/* Special case for contacts: because address books are separate accounts, changed contacts cause
|
||||
this method to be called with authority = ContactsContract.AUTHORITY. However the sync worker shall be run for the
|
||||
address book authority instead. */
|
||||
val workerAccount = accountSettings.account // main account in case of an address book account
|
||||
val workerAuthority =
|
||||
if (authority == ContactsContract.AUTHORITY)
|
||||
context.getString(R.string.address_books_authority)
|
||||
else
|
||||
authority
|
||||
|
||||
Logger.log.fine("Starting OneTimeSyncWorker for $workerAccount $workerAuthority and waiting for it")
|
||||
val workerName = OneTimeSyncWorker.enqueue(context, workerAccount, workerAuthority, upload = upload)
|
||||
|
||||
// Because we are not allowed to observe worker state on a background thread, we can not
|
||||
// use it to block the sync adapter. Instead we check periodically whether the sync has
|
||||
// finished, putting the thread to sleep in between checks.
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
|
||||
try {
|
||||
runBlocking {
|
||||
withTimeout(10 * 60 * 1000) { // block max. 10 minutes
|
||||
// wait for finished worker state
|
||||
workManager.getWorkInfosForUniqueWorkFlow(workerName).collect { info ->
|
||||
if (info.any { it.state.isFinished })
|
||||
cancel(CancellationException("$workerName has finished"))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
// waiting for work was cancelled, either by timeout or because the worker has finished
|
||||
Logger.log.log(Level.FINE, "Not waiting for OneTimeSyncWorker anymore (this is not an error)", e)
|
||||
}
|
||||
|
||||
Logger.log.fine("Returning to sync framework")
|
||||
}
|
||||
|
||||
override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
|
||||
Logger.log.log(Level.WARNING, "Security exception for $account/$authority")
|
||||
}
|
||||
|
||||
override fun onSyncCanceled() {
|
||||
Logger.log.info("Sync adapter requested cancellation – won't cancel sync, but also won't block sync framework anymore")
|
||||
|
||||
// unblock sync framework
|
||||
finished.complete(false)
|
||||
}
|
||||
|
||||
override fun onSyncCanceled(thread: Thread) = onSyncCanceled()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// exported sync adapter services; we need a separate class for each authority
|
||||
class AddressBooksSyncAdapterService: SyncAdapterService()
|
||||
class CalendarsSyncAdapterService: SyncAdapterService()
|
||||
class ContactsSyncAdapterService: SyncAdapterService()
|
||||
class JtxSyncAdapterService: SyncAdapterService()
|
||||
class OpenTasksSyncAdapterService: SyncAdapterService()
|
||||
class TasksOrgSyncAdapterService: SyncAdapterService()
|
||||
@@ -1,68 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import dagger.hilt.DefineComponent
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.lang.ref.WeakReference
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import javax.inject.Scope
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Scope
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class SyncScoped
|
||||
|
||||
/**
|
||||
* Custom Hilt component for running syncs, lifetime managed by [SyncComponentManager].
|
||||
* Dependencies installed in this component and scoped with [SyncScoped] (like SyncValidators)
|
||||
* will have a lifetime of all active syncs.
|
||||
*/
|
||||
@SyncScoped
|
||||
@DefineComponent(parent = SingletonComponent::class)
|
||||
interface SyncComponent
|
||||
|
||||
@DefineComponent.Builder
|
||||
interface SyncComponentBuilder {
|
||||
fun build(): SyncComponent
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the lifecycle of [SyncComponent] by using [WeakReference].
|
||||
*/
|
||||
@Singleton
|
||||
class SyncComponentManager @Inject constructor(
|
||||
val provider: Provider<SyncComponentBuilder>
|
||||
) {
|
||||
|
||||
private var componentRef: WeakReference<SyncComponent>? = null
|
||||
|
||||
/**
|
||||
* Returns a [SyncComponent]. When there is already a known [SyncComponent],
|
||||
* it will be used. Otherwise, a new one will be created and returned.
|
||||
*
|
||||
* It is then stored using a [WeakReference] – so as long as the component
|
||||
* stays in memory, it will always be returned. When it's not used anymore
|
||||
* by anyone, it can be removed by garbage collection. After this, it will be
|
||||
* created again when [get] is called.
|
||||
*
|
||||
* @return singleton [SyncComponent] (will be garbage collected when not referenced anymore)
|
||||
*/
|
||||
@Synchronized
|
||||
fun get(): SyncComponent {
|
||||
val component = componentRef?.get()
|
||||
|
||||
// check for cached component
|
||||
if (component != null)
|
||||
return component
|
||||
|
||||
// cached component not available, build new one
|
||||
val newComponent = provider.get().build()
|
||||
componentRef = WeakReference(newComponent)
|
||||
return newComponent
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,942 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.PendingIntent
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SyncResult
|
||||
import android.net.Uri
|
||||
import android.os.RemoteException
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import at.bitfire.dav4jvm.DavCollection
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Error
|
||||
import at.bitfire.dav4jvm.MultiResponseCallback
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.exception.ConflictException
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.exception.ForbiddenException
|
||||
import at.bitfire.dav4jvm.exception.GoneException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.exception.NotFoundException
|
||||
import at.bitfire.dav4jvm.exception.PreconditionFailedException
|
||||
import at.bitfire.dav4jvm.exception.ServiceUnavailableException
|
||||
import at.bitfire.dav4jvm.exception.UnauthorizedException
|
||||
import at.bitfire.dav4jvm.property.caldav.GetCTag
|
||||
import at.bitfire.dav4jvm.property.caldav.ScheduleTag
|
||||
import at.bitfire.dav4jvm.property.webdav.GetETag
|
||||
import at.bitfire.dav4jvm.property.webdav.SyncToken
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.db.SyncStats
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalCollection
|
||||
import at.bitfire.davdroid.resource.LocalContact
|
||||
import at.bitfire.davdroid.resource.LocalEvent
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.resource.LocalTask
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible
|
||||
import at.bitfire.davdroid.ui.account.AccountSettingsActivity
|
||||
import at.bitfire.ical4android.CalendarStorageException
|
||||
import at.bitfire.ical4android.Ical4Android
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.ical4android.UsesThreadContextClassLoader
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.RequestBody
|
||||
import org.apache.commons.lang3.exception.ContextedException
|
||||
import org.dmfs.tasks.contract.TaskContract
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.security.cert.CertificateException
|
||||
import java.time.Instant
|
||||
import java.util.LinkedList
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.logging.Level
|
||||
import javax.net.ssl.SSLHandshakeException
|
||||
|
||||
@UsesThreadContextClassLoader
|
||||
abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: LocalCollection<ResourceType>, RemoteType: DavCollection>(
|
||||
val context: Context,
|
||||
val account: Account,
|
||||
val accountSettings: AccountSettings,
|
||||
val httpClient: HttpClient,
|
||||
val extras: Array<String>,
|
||||
val authority: String,
|
||||
val syncResult: SyncResult,
|
||||
val localCollection: CollectionType
|
||||
) {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface SyncManagerEntryPoint {
|
||||
fun appDatabase(): AppDatabase
|
||||
}
|
||||
|
||||
enum class SyncAlgorithm {
|
||||
PROPFIND_REPORT,
|
||||
COLLECTION_SYNC
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
/** Maximum number of resources that are requested with one multiget request. */
|
||||
const val MAX_MULTIGET_RESOURCES = 10
|
||||
|
||||
const val DELAY_UNTIL_DEFAULT = 15*60L // 15 min
|
||||
const val DELAY_UNTIL_MIN = 1*60L // 1 min
|
||||
const val DELAY_UNTIL_MAX = 2*60*60L // 2 hours
|
||||
|
||||
/**
|
||||
* Context label for [org.apache.commons.lang3.exception.ContextedException].
|
||||
* Context value is the [at.bitfire.davdroid.resource.LocalResource]
|
||||
* which is related to the exception cause.
|
||||
*/
|
||||
const val EXCEPTION_CONTEXT_LOCAL_RESOURCE = "localResource"
|
||||
|
||||
/**
|
||||
* Context label for [org.apache.commons.lang3.exception.ContextedException].
|
||||
* Context value is the [okhttp3.HttpUrl] of the remote resource
|
||||
* which is related to the exception cause.
|
||||
*/
|
||||
const val EXCEPTION_CONTEXT_REMOTE_RESOURCE = "remoteResource"
|
||||
|
||||
/**
|
||||
* Returns appropriate sync retry delay in seconds, considering the servers suggestion
|
||||
* ([DELAY_UNTIL_DEFAULT] if no server suggestion).
|
||||
*
|
||||
* Takes current time into account to calculate intervals. Interval
|
||||
* will be restricted to values between [DELAY_UNTIL_MIN] and [DELAY_UNTIL_MAX].
|
||||
*
|
||||
* @param retryAfter optional server suggestion on how long to wait before retrying
|
||||
* @return until when to wait before sync can be retried
|
||||
*/
|
||||
fun getDelayUntil(retryAfter: Instant?): Instant {
|
||||
val now = Instant.now()
|
||||
|
||||
if (retryAfter == null)
|
||||
return now.plusSeconds(DELAY_UNTIL_DEFAULT)
|
||||
|
||||
// take server suggestion, but restricted to plausible min/max values
|
||||
val min = now.plusSeconds(DELAY_UNTIL_MIN)
|
||||
val max = now.plusSeconds(DELAY_UNTIL_MAX)
|
||||
return when {
|
||||
min > retryAfter -> min
|
||||
max < retryAfter -> max
|
||||
else -> retryAfter
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
init {
|
||||
// required for ServiceLoader -> ical4j -> ical4android
|
||||
Ical4Android.checkThreadContextClassLoader()
|
||||
}
|
||||
|
||||
protected val mainAccount = if (localCollection is LocalAddressBook)
|
||||
localCollection.mainAccount
|
||||
else
|
||||
account
|
||||
|
||||
protected val notificationManager = NotificationManagerCompat.from(context)
|
||||
protected val notificationTag = localCollection.tag
|
||||
|
||||
protected lateinit var collectionURL: HttpUrl
|
||||
protected lateinit var davCollection: RemoteType
|
||||
|
||||
protected var hasCollectionSync = false
|
||||
|
||||
|
||||
fun performSync() {
|
||||
// dismiss previous error notifications
|
||||
notificationManager.cancel(notificationTag, NotificationUtils.NOTIFY_SYNC_ERROR)
|
||||
|
||||
unwrapExceptions({
|
||||
Logger.log.info("Preparing synchronization")
|
||||
if (!prepare()) {
|
||||
Logger.log.info("No reason to synchronize, aborting")
|
||||
return@unwrapExceptions
|
||||
}
|
||||
|
||||
// log sync time
|
||||
val db = EntryPointAccessors.fromApplication(context, SyncManagerEntryPoint::class.java).appDatabase()
|
||||
db.runInTransaction {
|
||||
db.collectionDao().getByUrl(collectionURL.toString())?.let { collection ->
|
||||
db.syncStatsDao().insertOrReplace(
|
||||
SyncStats(0, collection.id, authority, System.currentTimeMillis())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Logger.log.info("Querying server capabilities")
|
||||
var remoteSyncState = queryCapabilities()
|
||||
|
||||
Logger.log.info("Processing local deletes/updates")
|
||||
val modificationsPresent = processLocallyDeleted() or uploadDirty() // bitwise OR guarantees that both expressions are evaluated
|
||||
|
||||
if (extras.contains(Syncer.SYNC_EXTRAS_FULL_RESYNC)) {
|
||||
Logger.log.info("Forcing re-synchronization of all entries")
|
||||
|
||||
// forget sync state of collection (→ initial sync in case of SyncAlgorithm.COLLECTION_SYNC)
|
||||
localCollection.lastSyncState = null
|
||||
remoteSyncState = null
|
||||
|
||||
// forget sync state of members (→ download all members again and update them locally)
|
||||
localCollection.forgetETags()
|
||||
}
|
||||
|
||||
if (modificationsPresent || syncRequired(remoteSyncState))
|
||||
when (syncAlgorithm()) {
|
||||
SyncAlgorithm.PROPFIND_REPORT -> {
|
||||
Logger.log.info("Sync algorithm: full listing as one result (PROPFIND/REPORT)")
|
||||
resetPresentRemotely()
|
||||
|
||||
// get current sync state
|
||||
if (modificationsPresent)
|
||||
remoteSyncState = querySyncState()
|
||||
|
||||
// list and process all entries at current sync state (which may be the same as or newer than remoteSyncState)
|
||||
Logger.log.info("Processing remote entries")
|
||||
syncRemote { callback ->
|
||||
listAllRemote(callback)
|
||||
}
|
||||
|
||||
Logger.log.info("Deleting entries which are not present remotely anymore")
|
||||
deleteNotPresentRemotely()
|
||||
|
||||
Logger.log.info("Post-processing")
|
||||
postProcess()
|
||||
|
||||
Logger.log.log(Level.INFO, "Saving sync state", remoteSyncState)
|
||||
localCollection.lastSyncState = remoteSyncState
|
||||
}
|
||||
SyncAlgorithm.COLLECTION_SYNC -> {
|
||||
var syncState = localCollection.lastSyncState?.takeIf { it.type == SyncState.Type.SYNC_TOKEN }
|
||||
|
||||
var initialSync = false
|
||||
if (syncState == null) {
|
||||
Logger.log.info("Starting initial sync")
|
||||
initialSync = true
|
||||
resetPresentRemotely()
|
||||
} else if (syncState.initialSync == true) {
|
||||
Logger.log.info("Continuing initial sync")
|
||||
initialSync = true
|
||||
}
|
||||
|
||||
var furtherChanges = false
|
||||
do {
|
||||
Logger.log.info("Listing changes since $syncState")
|
||||
syncRemote { callback ->
|
||||
try {
|
||||
val result = listRemoteChanges(syncState, callback)
|
||||
syncState = SyncState.fromSyncToken(result.first, initialSync)
|
||||
furtherChanges = result.second
|
||||
} catch(e: HttpException) {
|
||||
if (e.errors.contains(Error.VALID_SYNC_TOKEN)) {
|
||||
Logger.log.info("Sync token invalid, performing initial sync")
|
||||
initialSync = true
|
||||
resetPresentRemotely()
|
||||
|
||||
val result = listRemoteChanges(null, callback)
|
||||
syncState = SyncState.fromSyncToken(result.first, initialSync)
|
||||
furtherChanges = result.second
|
||||
} else
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
Logger.log.log(Level.INFO, "Saving sync state", syncState)
|
||||
localCollection.lastSyncState = syncState
|
||||
|
||||
Logger.log.info("Server has further changes: $furtherChanges")
|
||||
} while(furtherChanges)
|
||||
|
||||
if (initialSync) {
|
||||
// initial sync is finished, remove all local resources which have not been listed by server
|
||||
Logger.log.info("Deleting local resources which are not on server (anymore)")
|
||||
deleteNotPresentRemotely()
|
||||
|
||||
// remove initial sync flag
|
||||
syncState!!.initialSync = false
|
||||
Logger.log.log(Level.INFO, "Initial sync completed, saving sync state", syncState)
|
||||
localCollection.lastSyncState = syncState
|
||||
}
|
||||
|
||||
Logger.log.info("Post-processing")
|
||||
postProcess()
|
||||
}
|
||||
}
|
||||
else
|
||||
Logger.log.info("Remote collection didn't change, no reason to sync")
|
||||
|
||||
}, { e, local, remote ->
|
||||
when (e) {
|
||||
// sync was cancelled or account has been removed: re-throw to SyncAdapterService (now BaseSyncer)
|
||||
is InterruptedException,
|
||||
is InterruptedIOException,
|
||||
is InvalidAccountException ->
|
||||
throw e
|
||||
|
||||
// specific I/O errors
|
||||
is SSLHandshakeException -> {
|
||||
Logger.log.log(Level.WARNING, "SSL handshake failed", e)
|
||||
|
||||
// when a certificate is rejected by cert4android, the cause will be a CertificateException
|
||||
if (e.cause !is CertificateException)
|
||||
notifyException(e, local, remote)
|
||||
}
|
||||
|
||||
// specific HTTP errors
|
||||
is ServiceUnavailableException -> {
|
||||
Logger.log.log(Level.WARNING, "Got 503 Service unavailable, trying again later", e)
|
||||
// determine when to retry
|
||||
syncResult.delayUntil = getDelayUntil(e.retryAfter).epochSecond
|
||||
syncResult.stats.numIoExceptions++ // Indicate a soft error occurred
|
||||
}
|
||||
|
||||
// all others
|
||||
else ->
|
||||
notifyException(e, local, remote)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Prepares synchronization. Sets the lateinit properties [collectionURL] and [davCollection].
|
||||
*
|
||||
* @return whether synchronization shall be performed
|
||||
*/
|
||||
protected abstract fun prepare(): Boolean
|
||||
|
||||
/**
|
||||
* Queries the server for synchronization capabilities like specific report types,
|
||||
* data formats etc.
|
||||
*
|
||||
* Should also query and save the initial sync state (e.g. CTag/sync-token).
|
||||
*
|
||||
* @return current sync state
|
||||
*/
|
||||
protected abstract fun queryCapabilities(): SyncState?
|
||||
|
||||
/**
|
||||
* Processes locally deleted entries. This can mean:
|
||||
*
|
||||
* - forwarding them to the server (HTTP `DELETE`)
|
||||
* - resetting their local state so that they will be downloaded again because they're read-only
|
||||
*
|
||||
* @return whether local resources have been processed so that a synchronization is always necessary
|
||||
*/
|
||||
protected open fun processLocallyDeleted(): Boolean {
|
||||
var numDeleted = 0
|
||||
|
||||
// Remove locally deleted entries from server (if they have a name, i.e. if they were uploaded before),
|
||||
// but only if they don't have changed on the server. Then finally remove them from the local address book.
|
||||
val localList = localCollection.findDeleted()
|
||||
for (local in localList) {
|
||||
localExceptionContext(local) {
|
||||
val fileName = local.fileName
|
||||
if (fileName != null) {
|
||||
val lastScheduleTag = local.scheduleTag
|
||||
val lastETag = if (lastScheduleTag == null) local.eTag else null
|
||||
Logger.log.info("$fileName has been deleted locally -> deleting from server (ETag $lastETag / schedule-tag $lastScheduleTag)")
|
||||
|
||||
remoteExceptionContext(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build())) { remote ->
|
||||
try {
|
||||
remote.delete(ifETag = lastETag, ifScheduleTag = lastScheduleTag) {}
|
||||
numDeleted++
|
||||
} catch (e: HttpException) {
|
||||
Logger.log.warning("Couldn't delete $fileName from server; ignoring (may be downloaded again)")
|
||||
}
|
||||
}
|
||||
} else
|
||||
Logger.log.info("Removing local record #${local.id} which has been deleted locally and was never uploaded")
|
||||
local.delete()
|
||||
syncResult.stats.numDeletes++
|
||||
}
|
||||
}
|
||||
Logger.log.info("Removed $numDeleted record(s) from server")
|
||||
return numDeleted > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes locally modified resources to the server. This can mean:
|
||||
*
|
||||
* - uploading them to the server (HTTP `PUT`)
|
||||
* - resetting their local state so that they will be downloaded again because they're read-only
|
||||
*
|
||||
* @return whether local resources have been processed so that a synchronization is always necessary
|
||||
*/
|
||||
protected open fun uploadDirty(): Boolean {
|
||||
var numUploaded = 0
|
||||
|
||||
// upload dirty resources (parallelized)
|
||||
runBlocking {
|
||||
for (local in localCollection.findDirty())
|
||||
launch {
|
||||
localExceptionContext(local) {
|
||||
uploadDirty(local)
|
||||
numUploaded++
|
||||
}
|
||||
}
|
||||
}
|
||||
syncResult.stats.numEntries += numUploaded
|
||||
Logger.log.info("Sent $numUploaded record(s) to server")
|
||||
return numUploaded > 0
|
||||
}
|
||||
|
||||
protected fun uploadDirty(local: ResourceType) {
|
||||
val existingFileName = local.fileName
|
||||
|
||||
var newFileName: String? = null
|
||||
var eTag: String? = null
|
||||
var scheduleTag: String? = null
|
||||
val readTagsFromResponse: (okhttp3.Response) -> Unit = { response ->
|
||||
eTag = GetETag.fromResponse(response)?.eTag
|
||||
scheduleTag = ScheduleTag.fromResponse(response)?.scheduleTag
|
||||
}
|
||||
|
||||
try {
|
||||
if (existingFileName == null) { // new resource
|
||||
newFileName = local.prepareForUpload()
|
||||
|
||||
val uploadUrl = collectionURL.newBuilder().addPathSegment(newFileName).build()
|
||||
remoteExceptionContext(DavResource(httpClient.okHttpClient, uploadUrl)) { remote ->
|
||||
Logger.log.info("Uploading new record ${local.id} -> $newFileName")
|
||||
remote.put(generateUpload(local), ifNoneMatch = true, callback = readTagsFromResponse)
|
||||
}
|
||||
|
||||
} else /* existingFileName != null */ { // updated resource
|
||||
local.prepareForUpload()
|
||||
|
||||
val uploadUrl = collectionURL.newBuilder().addPathSegment(existingFileName).build()
|
||||
remoteExceptionContext(DavResource(httpClient.okHttpClient, uploadUrl)) { remote ->
|
||||
val lastScheduleTag = local.scheduleTag
|
||||
val lastETag = if (lastScheduleTag == null) local.eTag else null
|
||||
Logger.log.info("Uploading modified record ${local.id} -> $existingFileName (ETag=$lastETag, Schedule-Tag=$lastScheduleTag)")
|
||||
remote.put(generateUpload(local), ifETag = lastETag, ifScheduleTag = lastScheduleTag, callback = readTagsFromResponse)
|
||||
}
|
||||
}
|
||||
} catch (e: ContextedException) {
|
||||
when (val ex = e.cause) {
|
||||
is ForbiddenException -> {
|
||||
// HTTP 403 Forbidden
|
||||
// If and only if the upload failed because of missing permissions, treat it like 412.
|
||||
if (ex.errors.contains(Error.NEED_PRIVILEGES))
|
||||
Logger.log.log(Level.INFO, "Couldn't upload because of missing permissions, ignoring", ex)
|
||||
else
|
||||
throw e
|
||||
}
|
||||
is NotFoundException, is GoneException -> {
|
||||
// HTTP 404 Not Found (i.e. either original resource or the whole collection is not there anymore)
|
||||
if (local.scheduleTag != null || local.eTag != null) { // this was an update of a previously existing resource
|
||||
Logger.log.info("Original version of locally modified resource is not there (anymore), trying as fresh upload")
|
||||
if (local.scheduleTag != null) // contacts don't support scheduleTag, don't try to set it without check
|
||||
local.scheduleTag = null
|
||||
local.eTag = null
|
||||
uploadDirty(local) // if this fails with 404, too, the collection is gone
|
||||
return
|
||||
} else
|
||||
throw e // the collection is probably gone
|
||||
}
|
||||
is ConflictException -> {
|
||||
// HTTP 409 Conflict
|
||||
// We can't interact with the user to resolve the conflict, so we treat 409 like 412.
|
||||
Logger.log.info("Edit conflict, ignoring")
|
||||
}
|
||||
is PreconditionFailedException -> {
|
||||
// HTTP 412 Precondition failed: Resource has been modified on the server in the meanwhile.
|
||||
// Ignore this condition so that the resource can be downloaded and reset again.
|
||||
Logger.log.info("Resource has been modified on the server before upload, ignoring")
|
||||
}
|
||||
else -> throw e
|
||||
}
|
||||
}
|
||||
|
||||
if (eTag != null)
|
||||
Logger.log.fine("Received new ETag=$eTag after uploading")
|
||||
else
|
||||
Logger.log.fine("Didn't receive new ETag after uploading, setting to null")
|
||||
|
||||
local.clearDirty(newFileName, eTag, scheduleTag)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the request body (iCalendar or vCard) from a local resource.
|
||||
*
|
||||
* @param resource local resource to generate the body from
|
||||
*
|
||||
* @return iCalendar or vCard (content + Content-Type) that can be uploaded to the server
|
||||
*/
|
||||
protected abstract fun generateUpload(resource: ResourceType): RequestBody
|
||||
|
||||
|
||||
/**
|
||||
* Determines whether a sync is required because there were changes on the server.
|
||||
* For instance, this method can compare the collection's `CTag`/`sync-token` with
|
||||
* the last known local value.
|
||||
*
|
||||
* When local changes have been uploaded ([processLocallyDeleted] and/or
|
||||
* [uploadDirty] were true), a sync is always required and this method
|
||||
* should *not* be evaluated.
|
||||
*
|
||||
* Will return _true_ if [Syncer.SYNC_EXTRAS_RESYNC] and/or
|
||||
* [Syncer.SYNC_EXTRAS_FULL_RESYNC] is set in [extras].
|
||||
*
|
||||
* @param state remote sync state to compare local sync state with
|
||||
*
|
||||
* @return whether data has been changed on the server, i.e. whether running the
|
||||
* sync algorithm is required
|
||||
*/
|
||||
protected open fun syncRequired(state: SyncState?): Boolean {
|
||||
if (extras.contains(Syncer.SYNC_EXTRAS_RESYNC) ||
|
||||
extras.contains(Syncer.SYNC_EXTRAS_FULL_RESYNC))
|
||||
return true
|
||||
|
||||
val localState = localCollection.lastSyncState
|
||||
Logger.log.info("Local sync state = $localState, remote sync state = $state")
|
||||
return when (state?.type) {
|
||||
SyncState.Type.SYNC_TOKEN -> {
|
||||
val lastKnownToken = localState?.takeIf { it.type == SyncState.Type.SYNC_TOKEN }?.value
|
||||
lastKnownToken != state.value
|
||||
}
|
||||
SyncState.Type.CTAG -> {
|
||||
val lastKnownCTag = localState?.takeIf { it.type == SyncState.Type.CTAG }?.value
|
||||
lastKnownCTag != state.value
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines which sync algorithm to use.
|
||||
* @return
|
||||
* - [SyncAlgorithm.PROPFIND_REPORT]: list all resources (with plain WebDAV
|
||||
* PROPFIND or specific REPORT requests), then compare and synchronize
|
||||
* - [SyncAlgorithm.COLLECTION_SYNC]: use incremental collection synchronization (RFC 6578)
|
||||
*/
|
||||
protected abstract fun syncAlgorithm(): SyncAlgorithm
|
||||
|
||||
/**
|
||||
* Marks all local resources which shall be taken into consideration for this
|
||||
* sync as "synchronizing". Purpose of marking is that resources which have been marked
|
||||
* and are not present remotely anymore can be deleted.
|
||||
*
|
||||
* Used together with [deleteNotPresentRemotely].
|
||||
*/
|
||||
protected open fun resetPresentRemotely() {
|
||||
val number = localCollection.markNotDirty(0)
|
||||
Logger.log.info("Number of local non-dirty entries: $number")
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a callback to list remote resources. All resources from the returned
|
||||
* list are downloaded and processed.
|
||||
*
|
||||
* @param listRemote function to list remote resources (for instance, all since a certain sync-token)
|
||||
*/
|
||||
protected open fun syncRemote(listRemote: (MultiResponseCallback) -> Unit) {
|
||||
// thread-safe sync stats
|
||||
val nInserted = AtomicInteger()
|
||||
val nUpdated = AtomicInteger()
|
||||
val nDeleted = AtomicInteger()
|
||||
val nSkipped = AtomicInteger()
|
||||
|
||||
runBlocking {
|
||||
// download queue
|
||||
val toDownload = LinkedBlockingQueue<HttpUrl>()
|
||||
fun download(url: HttpUrl?) {
|
||||
if (url != null)
|
||||
toDownload += url
|
||||
|
||||
if (toDownload.size >= MAX_MULTIGET_RESOURCES || url == null) {
|
||||
while (toDownload.size > 0) {
|
||||
val bunch = LinkedList<HttpUrl>()
|
||||
toDownload.drainTo(bunch, MAX_MULTIGET_RESOURCES)
|
||||
launch {
|
||||
downloadRemote(bunch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
coroutineScope { // structured concurrency: blocks until all inner coroutines are finished
|
||||
listRemote { response, relation ->
|
||||
// ignore non-members
|
||||
if (relation != Response.HrefRelation.MEMBER)
|
||||
return@listRemote
|
||||
|
||||
// ignore collections
|
||||
if (response[at.bitfire.dav4jvm.property.webdav.ResourceType::class.java]?.types?.contains(at.bitfire.dav4jvm.property.webdav.ResourceType.COLLECTION) == true)
|
||||
return@listRemote
|
||||
|
||||
val name = response.hrefName()
|
||||
|
||||
if (response.isSuccess()) {
|
||||
Logger.log.fine("Found remote resource: $name")
|
||||
|
||||
launch {
|
||||
localExceptionContext(localCollection.findByName(name)) { local ->
|
||||
if (local == null) {
|
||||
Logger.log.info("$name has been added remotely, queueing download")
|
||||
download(response.href)
|
||||
nInserted.incrementAndGet()
|
||||
} else {
|
||||
val localETag = local.eTag
|
||||
val remoteETag = response[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Server didn't provide ETag")
|
||||
if (localETag == remoteETag) {
|
||||
Logger.log.info("$name has not been changed on server (ETag still $remoteETag)")
|
||||
nSkipped.incrementAndGet()
|
||||
} else {
|
||||
Logger.log.info("$name has been changed on server (current ETag=$remoteETag, last known ETag=$localETag)")
|
||||
download(response.href)
|
||||
nUpdated.incrementAndGet()
|
||||
}
|
||||
|
||||
// mark as remotely present, so that this resource won't be deleted at the end
|
||||
local.updateFlags(LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} else if (response.status?.code == HttpURLConnection.HTTP_NOT_FOUND) {
|
||||
// collection sync: resource has been deleted on remote server
|
||||
launch {
|
||||
localExceptionContext(localCollection.findByName(name)) { local ->
|
||||
Logger.log.info("$name has been deleted on server, deleting locally")
|
||||
local?.delete()
|
||||
nDeleted.incrementAndGet()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// download remaining resources
|
||||
download(null)
|
||||
}
|
||||
|
||||
// update sync stats
|
||||
with(syncResult.stats) {
|
||||
numInserts += nInserted.get()
|
||||
numUpdates += nUpdated.get()
|
||||
numDeletes += nDeleted.get()
|
||||
numSkippedEntries += nSkipped.get()
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract fun listAllRemote(callback: MultiResponseCallback)
|
||||
|
||||
protected open fun listRemoteChanges(syncState: SyncState?, callback: MultiResponseCallback): Pair<SyncToken, Boolean> {
|
||||
var furtherResults = false
|
||||
|
||||
val report = davCollection.reportChanges(
|
||||
syncState?.takeIf { syncState.type == SyncState.Type.SYNC_TOKEN }?.value,
|
||||
false, null,
|
||||
GetETag.NAME) { response, relation ->
|
||||
when (relation) {
|
||||
Response.HrefRelation.SELF ->
|
||||
furtherResults = response.status?.code == 507
|
||||
|
||||
Response.HrefRelation.MEMBER ->
|
||||
callback.onResponse(response, relation)
|
||||
|
||||
else ->
|
||||
Logger.log.fine("Unexpected sync-collection response: $response")
|
||||
}
|
||||
}
|
||||
|
||||
var syncToken: SyncToken? = null
|
||||
report.filterIsInstance(SyncToken::class.java).firstOrNull()?.let {
|
||||
syncToken = it
|
||||
}
|
||||
if (syncToken == null)
|
||||
throw DavException("Received sync-collection response without sync-token")
|
||||
|
||||
return Pair(syncToken!!, furtherResults)
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads and processes resources, given as a list of URLs. Will be called with a list
|
||||
* of changed/new remote resources.
|
||||
*
|
||||
* Implementations should not use GET to fetch single resources, but always multi-get, even
|
||||
* for single resources for these reasons:
|
||||
*
|
||||
* 1. GET can only be used without HTTP compression, because it may change the ETag.
|
||||
* multi-get sends the ETag in the XML body, so there's no problem with compression.
|
||||
* 2. Some servers are wrongly configured to suppress the ETag header in the response.
|
||||
* With multi-get, the ETag is in the XML body, so it won't be affected by that.
|
||||
* 3. If there are two methods to download resources (GET and multi-get), both methods
|
||||
* have to be implemented, tested and maintained. Given that multi-get is required
|
||||
* in any case, it's better to have only one method.
|
||||
* 4. For users, it's strange behavior when DAVx5 can download multiple remote changes,
|
||||
* but not a single one (or vice versa). So only one method is more user-friendly.
|
||||
* 5. March 2020: iCloud now crashes with HTTP 500 upon CardDAV GET requests.
|
||||
*/
|
||||
protected abstract fun downloadRemote(bunch: List<HttpUrl>)
|
||||
|
||||
/**
|
||||
* Locally deletes entries which are
|
||||
* 1. not dirty and
|
||||
* 2. not marked as [LocalResource.FLAG_REMOTELY_PRESENT].
|
||||
*
|
||||
* Used together with [resetPresentRemotely] when a full listing has been received from
|
||||
* the server to locally delete resources which are not present remotely (anymore).
|
||||
*/
|
||||
protected open fun deleteNotPresentRemotely() {
|
||||
val removed = localCollection.removeNotDirtyMarked(0)
|
||||
Logger.log.info("Removed $removed local resources which are not present on the server anymore")
|
||||
syncResult.stats.numDeletes += removed
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-processing of synchronized entries, for instance contact group membership operations.
|
||||
*/
|
||||
protected abstract fun postProcess()
|
||||
|
||||
|
||||
// sync helpers
|
||||
|
||||
protected fun syncState(dav: Response) =
|
||||
dav[SyncToken::class.java]?.token?.let {
|
||||
SyncState(SyncState.Type.SYNC_TOKEN, it)
|
||||
} ?:
|
||||
dav[GetCTag::class.java]?.cTag?.let {
|
||||
SyncState(SyncState.Type.CTAG, it)
|
||||
}
|
||||
|
||||
private fun querySyncState(): SyncState? {
|
||||
var state: SyncState? = null
|
||||
davCollection.propfind(0, GetCTag.NAME, SyncToken.NAME) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF)
|
||||
state = syncState(response)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
|
||||
// exception helpers
|
||||
|
||||
private fun notifyException(e: Throwable, local: ResourceType?, remote: HttpUrl?) {
|
||||
val message: String
|
||||
|
||||
when (e) {
|
||||
is IOException,
|
||||
is InterruptedIOException -> {
|
||||
Logger.log.log(Level.WARNING, "I/O error", e)
|
||||
message = context.getString(R.string.sync_error_io, e.localizedMessage)
|
||||
syncResult.stats.numIoExceptions++
|
||||
}
|
||||
is UnauthorizedException -> {
|
||||
Logger.log.log(Level.SEVERE, "Not authorized anymore", e)
|
||||
message = context.getString(R.string.sync_error_authentication_failed)
|
||||
syncResult.stats.numAuthExceptions++
|
||||
}
|
||||
is HttpException, is DavException -> {
|
||||
Logger.log.log(Level.SEVERE, "HTTP/DAV exception", e)
|
||||
message = context.getString(R.string.sync_error_http_dav, e.localizedMessage)
|
||||
syncResult.stats.numParseExceptions++ // numIoExceptions would indicate a soft error
|
||||
}
|
||||
is CalendarStorageException, is ContactsStorageException, is RemoteException -> {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't access local storage", e)
|
||||
message = context.getString(R.string.sync_error_local_storage, e.localizedMessage)
|
||||
syncResult.databaseError = true
|
||||
}
|
||||
else -> {
|
||||
Logger.log.log(Level.SEVERE, "Unclassified sync error", e)
|
||||
message = e.localizedMessage ?: e::class.java.simpleName
|
||||
syncResult.stats.numParseExceptions++
|
||||
}
|
||||
}
|
||||
|
||||
val contentIntent: Intent
|
||||
var viewItemAction: NotificationCompat.Action? = null
|
||||
if (e is UnauthorizedException) {
|
||||
contentIntent = Intent(context, AccountSettingsActivity::class.java)
|
||||
contentIntent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT,
|
||||
if (authority == ContactsContract.AUTHORITY)
|
||||
mainAccount
|
||||
else
|
||||
account)
|
||||
} else {
|
||||
contentIntent = buildDebugInfoIntent(e, local, remote)
|
||||
if (local != null)
|
||||
viewItemAction = buildViewItemAction(local)
|
||||
}
|
||||
|
||||
// to make the PendingIntent unique
|
||||
contentIntent.data = Uri.parse("davdroid:exception/${e.hashCode()}")
|
||||
|
||||
val channel: String
|
||||
val priority: Int
|
||||
if (e is IOException) {
|
||||
channel = NotificationUtils.CHANNEL_SYNC_IO_ERRORS
|
||||
priority = NotificationCompat.PRIORITY_MIN
|
||||
} else {
|
||||
channel = NotificationUtils.CHANNEL_SYNC_ERRORS
|
||||
priority = NotificationCompat.PRIORITY_DEFAULT
|
||||
}
|
||||
|
||||
val builder = NotificationUtils.newBuilder(context, channel)
|
||||
builder .setSmallIcon(R.drawable.ic_sync_problem_notify)
|
||||
.setContentTitle(localCollection.title)
|
||||
.setContentText(message)
|
||||
.setStyle(NotificationCompat.BigTextStyle(builder).bigText(message))
|
||||
.setSubText(mainAccount?.name)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||
.setPriority(priority)
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
viewItemAction?.let { builder.addAction(it) }
|
||||
|
||||
notificationManager.notifyIfPossible(notificationTag, NotificationUtils.NOTIFY_SYNC_ERROR, builder.build())
|
||||
}
|
||||
|
||||
private fun buildDebugInfoIntent(e: Throwable, local: ResourceType?, remote: HttpUrl?) =
|
||||
DebugInfoActivity.IntentBuilder(context)
|
||||
.withAccount(account)
|
||||
.withAuthority(authority)
|
||||
.withCause(e)
|
||||
.withLocalResource(
|
||||
try {
|
||||
local.toString()
|
||||
} catch (e: OutOfMemoryError) {
|
||||
// for instance because of a huge contact photo; maybe we're lucky and can fetch it
|
||||
null
|
||||
}
|
||||
)
|
||||
.withRemoteResource(remote)
|
||||
.build()
|
||||
|
||||
private fun buildViewItemAction(local: ResourceType): NotificationCompat.Action? {
|
||||
Logger.log.log(Level.FINE, "Adding view action for local resource", local)
|
||||
val intent = local.id?.let { id ->
|
||||
when (local) {
|
||||
is LocalContact ->
|
||||
Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(ContactsContract.RawContacts.CONTENT_URI, id))
|
||||
is LocalEvent ->
|
||||
Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, id))
|
||||
is LocalTask ->
|
||||
Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(TaskContract.Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority), id))
|
||||
else ->
|
||||
null
|
||||
}
|
||||
}
|
||||
return if (intent != null && context.packageManager.resolveActivity(intent, 0) != null)
|
||||
NotificationCompat.Action(android.R.drawable.ic_menu_view, context.getString(R.string.sync_error_view_item),
|
||||
PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||
else
|
||||
null
|
||||
}
|
||||
|
||||
protected fun notifyInvalidResource(e: Throwable, fileName: String) {
|
||||
val intent = buildDebugInfoIntent(e, null, collectionURL.resolve(fileName))
|
||||
|
||||
val builder = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_WARNINGS)
|
||||
builder .setSmallIcon(R.drawable.ic_warning_notify)
|
||||
.setContentTitle(notifyInvalidResourceTitle())
|
||||
.setContentText(context.getString(R.string.sync_invalid_resources_ignoring))
|
||||
.setSubText(mainAccount?.name)
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.priority = NotificationCompat.PRIORITY_LOW
|
||||
notificationManager.notifyIfPossible(notificationTag, NotificationUtils.NOTIFY_INVALID_RESOURCE, builder.build())
|
||||
}
|
||||
|
||||
protected abstract fun notifyInvalidResourceTitle(): String
|
||||
|
||||
protected fun<T: ResourceType?, R> localExceptionContext(local: T, body: (T) -> R): R {
|
||||
try {
|
||||
return body(local)
|
||||
} catch (e: ContextedException) {
|
||||
e.addContextValue(EXCEPTION_CONTEXT_LOCAL_RESOURCE, local)
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
if (local != null)
|
||||
throw ContextedException(e).setContextValue(EXCEPTION_CONTEXT_LOCAL_RESOURCE, local)
|
||||
else
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
protected fun<T: DavResource, R> remoteExceptionContext(remote: T, body: (T) -> R): R {
|
||||
try {
|
||||
return body(remote)
|
||||
} catch (e: ContextedException) {
|
||||
e.addContextValue(EXCEPTION_CONTEXT_REMOTE_RESOURCE, remote.location)
|
||||
throw e
|
||||
} catch(e: Throwable) {
|
||||
throw ContextedException(e).setContextValue(EXCEPTION_CONTEXT_REMOTE_RESOURCE, remote.location)
|
||||
}
|
||||
}
|
||||
|
||||
protected fun<T> responseExceptionContext(remote: Response, body: (Response) -> T): T {
|
||||
try {
|
||||
return body(remote)
|
||||
} catch (e: ContextedException) {
|
||||
e.addContextValue(EXCEPTION_CONTEXT_REMOTE_RESOURCE, remote.href)
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
throw ContextedException(e).setContextValue(EXCEPTION_CONTEXT_REMOTE_RESOURCE, remote.href)
|
||||
}
|
||||
}
|
||||
|
||||
protected fun<R> remoteExceptionContext(body: (RemoteType) -> R) =
|
||||
remoteExceptionContext(davCollection, body)
|
||||
|
||||
private fun unwrapExceptions(body: () -> Unit, handler: (e: Throwable, local: ResourceType?, remote: HttpUrl?) -> Unit) {
|
||||
var ex: Throwable? = null
|
||||
try {
|
||||
body()
|
||||
} catch(e: Throwable) {
|
||||
ex = e
|
||||
}
|
||||
|
||||
var local: ResourceType? = null
|
||||
var remote: HttpUrl? = null
|
||||
|
||||
if (ex is ContextedException) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
// we want the innermost context value, which is the first one
|
||||
(ex.getFirstContextValue(EXCEPTION_CONTEXT_LOCAL_RESOURCE) as? ResourceType)?.let {
|
||||
local = it
|
||||
}
|
||||
(ex.getFirstContextValue(EXCEPTION_CONTEXT_REMOTE_RESOURCE) as? HttpUrl)?.let {
|
||||
remote = it
|
||||
}
|
||||
ex = ex.cause
|
||||
}
|
||||
|
||||
if (ex != null)
|
||||
handler(ex, local, remote)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.util.TaskUtils
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
/**
|
||||
* Utility methods related to synchronization management (authorities, workers etc.)
|
||||
*/
|
||||
object SyncUtils {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface SyncUtilsEntryPoint {
|
||||
fun appDatabase(): AppDatabase
|
||||
fun settingsManager(): SettingsManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of all available sync authorities:
|
||||
*
|
||||
* 1. calendar authority
|
||||
* 2. address books authority
|
||||
* 3. current tasks authority (if available)
|
||||
*
|
||||
* Checking the availability of authorities may be relatively expensive, so the
|
||||
* result should be cached for the current operation.
|
||||
*
|
||||
* @return list of available sync authorities for main accounts
|
||||
*/
|
||||
fun syncAuthorities(context: Context): List<String> {
|
||||
val result = mutableListOf(
|
||||
CalendarContract.AUTHORITY,
|
||||
context.getString(R.string.address_books_authority)
|
||||
)
|
||||
TaskUtils.currentProvider(context)?.let { taskProvider ->
|
||||
result += taskProvider.authority
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.ThreadFactory
|
||||
import java.util.concurrent.ThreadPoolExecutor
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object SyncWorkDispatcher {
|
||||
|
||||
private var _dispatcher: CoroutineDispatcher? = null
|
||||
|
||||
/**
|
||||
* We use our own dispatcher to
|
||||
*
|
||||
* - make sure that all threads have [Thread.getContextClassLoader] set,
|
||||
* which is required for dav4jvm and ical4j (because they rely on [ServiceLoader]),
|
||||
* - control the global number of sync worker threads.
|
||||
*/
|
||||
@Synchronized
|
||||
fun getInstance(context: Context): CoroutineDispatcher {
|
||||
// prefer cached work dispatcher
|
||||
_dispatcher?.let { return it }
|
||||
|
||||
val newDispatcher = createDispatcher(context.applicationContext.classLoader)
|
||||
_dispatcher = newDispatcher
|
||||
|
||||
return newDispatcher
|
||||
}
|
||||
|
||||
private fun createDispatcher(classLoader: ClassLoader) =
|
||||
ThreadPoolExecutor(
|
||||
0, Runtime.getRuntime().availableProcessors(),
|
||||
10, TimeUnit.SECONDS, LinkedBlockingQueue(),
|
||||
object: ThreadFactory {
|
||||
val group = ThreadGroup("sync-work")
|
||||
override fun newThread(r: Runnable) =
|
||||
Thread(group, r).apply {
|
||||
contextClassLoader = classLoader
|
||||
}
|
||||
}
|
||||
).asCoroutineDispatcher()
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user