mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2026-01-06 05:47:50 -05:00
Compare commits
589 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c8f74ab14 | ||
|
|
03cbe4f665 | ||
|
|
a76829c3dd | ||
|
|
d83a4df524 | ||
|
|
afdfb735bd | ||
|
|
414f771156 | ||
|
|
c690dfd148 | ||
|
|
50775f26ce | ||
|
|
40a1748461 | ||
|
|
63e360fb3a | ||
|
|
d7216f5ffa | ||
|
|
a749a9fd34 | ||
|
|
6951397945 | ||
|
|
6153b3aafe | ||
|
|
325dd41820 | ||
|
|
be94ee19d8 | ||
|
|
d03ef78db9 | ||
|
|
e7e580132f | ||
|
|
a7c62d11ca | ||
|
|
d978e6a17d | ||
|
|
87457bf6d4 | ||
|
|
bff9c87c1f | ||
|
|
d27261f18a | ||
|
|
affce28652 | ||
|
|
b319fef88a | ||
|
|
6369ae1d36 | ||
|
|
8fe093d975 | ||
|
|
e99acb2921 | ||
|
|
88e0c16f83 | ||
|
|
b348f54f95 | ||
|
|
b75f8d140a | ||
|
|
206c2e8aa9 | ||
|
|
e8f14c3745 | ||
|
|
c2e6514a3c | ||
|
|
676f74a528 | ||
|
|
22b9624c0a | ||
|
|
f22dd3e013 | ||
|
|
913b395e56 | ||
|
|
f9974dbb7e | ||
|
|
49fae85852 | ||
|
|
3addab5bc0 | ||
|
|
1bfacff3df | ||
|
|
643c4f04c9 | ||
|
|
517b60aa21 | ||
|
|
98eeaf171e | ||
|
|
9320de54c3 | ||
|
|
24730d1925 | ||
|
|
94fd665b6d | ||
|
|
7d9d9ea57c | ||
|
|
bc1b1ca40f | ||
|
|
4ff8a5eb4e | ||
|
|
517a859dbf | ||
|
|
1b6689133a | ||
|
|
8a18e1e738 | ||
|
|
f241de264d | ||
|
|
2cf73f0f07 | ||
|
|
15008c1a53 | ||
|
|
c90e491739 | ||
|
|
1b5ea52138 | ||
|
|
48478c421f | ||
|
|
f4823f9524 | ||
|
|
2abe164e08 | ||
|
|
fa5856604d | ||
|
|
c88508ea66 | ||
|
|
e1c5fb2bfd | ||
|
|
9536d43047 | ||
|
|
273c23ee44 | ||
|
|
a34b3b1fee | ||
|
|
6157d6e767 | ||
|
|
013729ed73 | ||
|
|
2287b802ce | ||
|
|
0166008ab6 | ||
|
|
739bf3e53d | ||
|
|
e36102721f | ||
|
|
5ef9693ac8 | ||
|
|
19320dda17 | ||
|
|
322f9a0da0 | ||
|
|
ba20d74b64 | ||
|
|
6a512e1f0f | ||
|
|
4300954101 | ||
|
|
2fe19e9342 | ||
|
|
e764d31d18 | ||
|
|
8d0d920033 | ||
|
|
e2fcbbea0b | ||
|
|
1cefa88b98 | ||
|
|
be4a650deb | ||
|
|
ab47b2b222 | ||
|
|
7f301640da | ||
|
|
83656ef497 | ||
|
|
77c54a2150 | ||
|
|
16e3526a66 | ||
|
|
3ae5653da4 | ||
|
|
c02b033f8c | ||
|
|
4afe4837e7 | ||
|
|
56813ad4d6 | ||
|
|
036fc405f4 | ||
|
|
95173dcd7d | ||
|
|
be754d6ede | ||
|
|
20097c0b69 | ||
|
|
0d1c265e97 | ||
|
|
7ce739c271 | ||
|
|
241957f2dd | ||
|
|
3dea33e100 | ||
|
|
f8f60f4b52 | ||
|
|
0d75cf0cdc | ||
|
|
556843df4f | ||
|
|
8b12cb7a29 | ||
|
|
07c5cdc754 | ||
|
|
1d33e4c311 | ||
|
|
7d4689969a | ||
|
|
b863d355f6 | ||
|
|
bcd468f2e5 | ||
|
|
21fdf2cebc | ||
|
|
ea071bbd1a | ||
|
|
bc5f1e935e | ||
|
|
72749addcd | ||
|
|
6abbd019ad | ||
|
|
67b1685d01 | ||
|
|
4bbb2b8419 | ||
|
|
8bdf03bfc5 | ||
|
|
8b43677d01 | ||
|
|
e454fa398a | ||
|
|
f5b1194599 | ||
|
|
e549a25812 | ||
|
|
fb90955b2a | ||
|
|
a7f0161983 | ||
|
|
45efb88b1f | ||
|
|
2c022d46fe | ||
|
|
50c4ee94e2 | ||
|
|
d06d24a13e | ||
|
|
2c5cdf813a | ||
|
|
0250baeeb3 | ||
|
|
05d1970754 | ||
|
|
a97be98b11 | ||
|
|
1625f092fd | ||
|
|
c0ff8f61f9 | ||
|
|
dbfb74a16f | ||
|
|
39b277bc6d | ||
|
|
d09c70f52b | ||
|
|
2f0ee8e230 | ||
|
|
c320094b3b | ||
|
|
5b12015307 | ||
|
|
e7d2c23989 | ||
|
|
80971c52b5 | ||
|
|
23abf7c1f0 | ||
|
|
613b88ad5e | ||
|
|
b09d6c13b5 | ||
|
|
e30ae04534 | ||
|
|
0ed54bb671 | ||
|
|
889ebe160e | ||
|
|
602e14d2bf | ||
|
|
7ed275712e | ||
|
|
3dc3e75721 | ||
|
|
a63e717ca7 | ||
|
|
c0044ae901 | ||
|
|
edabdbadc9 | ||
|
|
47543e448c | ||
|
|
5ff88c5b2b | ||
|
|
eadfa39760 | ||
|
|
2342bc92ca | ||
|
|
f0d150d491 | ||
|
|
949a1916b5 | ||
|
|
efa4b7dc66 | ||
|
|
971be44ffe | ||
|
|
88efd5ab04 | ||
|
|
b44306eaf2 | ||
|
|
abb0850889 | ||
|
|
7760bddca7 | ||
|
|
60527e83fe | ||
|
|
54cc9b39e1 | ||
|
|
be9d404e6c | ||
|
|
fd40b5a4e3 | ||
|
|
abb142d118 | ||
|
|
7a7a940089 | ||
|
|
f0e1e03095 | ||
|
|
2358996940 | ||
|
|
09b42fc940 | ||
|
|
9c796f8226 | ||
|
|
9a635875a1 | ||
|
|
9d598cead7 | ||
|
|
2915a1f2b6 | ||
|
|
5316fb42a5 | ||
|
|
1c3a5ceb09 | ||
|
|
c34ca0f8e6 | ||
|
|
b9f629f6ce | ||
|
|
c6e37fcc58 | ||
|
|
cb8c1f45ad | ||
|
|
f7e1b97a66 | ||
|
|
90a70f39e8 | ||
|
|
9226d867c3 | ||
|
|
216579680d | ||
|
|
fe8b50c76d | ||
|
|
9ab7b3b105 | ||
|
|
b90ec5b64c | ||
|
|
b197ac7da2 | ||
|
|
9aa58d170f | ||
|
|
553b782339 | ||
|
|
8b6376e01d | ||
|
|
5da229ed88 | ||
|
|
a0bb681f48 | ||
|
|
2e3987e6af | ||
|
|
f72588a691 | ||
|
|
56fc2121f8 | ||
|
|
c863d05f3f | ||
|
|
f0ff12ebb9 | ||
|
|
87cb43b0e6 | ||
|
|
2e073897b8 | ||
|
|
bdc032d1b9 | ||
|
|
d07fcdcddc | ||
|
|
5f2f151e14 | ||
|
|
33ddb06272 | ||
|
|
ef9f13d1d6 | ||
|
|
6b9520287b | ||
|
|
3944ab0222 | ||
|
|
103459464c | ||
|
|
be2d10278d | ||
|
|
f4b864b3d0 | ||
|
|
a576701ee3 | ||
|
|
40bca54c09 | ||
|
|
88efbd7a00 | ||
|
|
292eb3bb52 | ||
|
|
bfddf92a3b | ||
|
|
872bfa2f40 | ||
|
|
73d9dff1b7 | ||
|
|
fd84ff37e5 | ||
|
|
d280ce9d63 | ||
|
|
67c7be900a | ||
|
|
c9cd6de476 | ||
|
|
924ef0243d | ||
|
|
62d9668081 | ||
|
|
ab6fa7ecbf | ||
|
|
74b0e5cd31 | ||
|
|
e2960ab572 | ||
|
|
50bd119871 | ||
|
|
c1f3165f5d | ||
|
|
915cd6ec44 | ||
|
|
73e936aeb5 | ||
|
|
190fc020ae | ||
|
|
c40a6bca2f | ||
|
|
3129fd909f | ||
|
|
31c77a1d57 | ||
|
|
380562bd28 | ||
|
|
80b1cb55c7 | ||
|
|
59dd66383e | ||
|
|
44af04e310 | ||
|
|
adf45cb569 | ||
|
|
ab88020c15 | ||
|
|
e27c6fde08 | ||
|
|
3135af78af | ||
|
|
9200ec89e1 | ||
|
|
b37a2ad0e0 | ||
|
|
1df42350cc | ||
|
|
3dbd5e3d18 | ||
|
|
7fdcb3710b | ||
|
|
2f9f4f1d7b | ||
|
|
9fc3921b32 | ||
|
|
4f4a22a14e | ||
|
|
16c44d8ad3 | ||
|
|
6cd2aa783e | ||
|
|
c2e161dac6 | ||
|
|
5a686a9a4c | ||
|
|
7ba98d1c71 | ||
|
|
fee5ab5064 | ||
|
|
6e1b42e6a4 | ||
|
|
c1ce953e3e | ||
|
|
3a6db13df8 | ||
|
|
d1d771b16a | ||
|
|
f986d9807e | ||
|
|
e3f33a5603 | ||
|
|
702888e9bd | ||
|
|
6b41849cae | ||
|
|
9dfb23b1d2 | ||
|
|
3468473f63 | ||
|
|
bc63559a24 | ||
|
|
3689df1385 | ||
|
|
dd3b326f66 | ||
|
|
e2ca9eb7d7 | ||
|
|
b001be1626 | ||
|
|
e09714af32 | ||
|
|
a4a816296b | ||
|
|
65db706e06 | ||
|
|
abd0eb533e | ||
|
|
323cd48a30 | ||
|
|
07b59fb6be | ||
|
|
3e2e17bdb2 | ||
|
|
73c5c9ef64 | ||
|
|
12261a23ea | ||
|
|
e19b045494 | ||
|
|
076962e5ed | ||
|
|
04306ed919 | ||
|
|
93abef95f7 | ||
|
|
54cc3bec9f | ||
|
|
d4324bcf8e | ||
|
|
eff4438e9f | ||
|
|
1f7298f947 | ||
|
|
c823eb6efd | ||
|
|
7e97017c41 | ||
|
|
5e3b98a676 | ||
|
|
8d60ee8d12 | ||
|
|
45af16afa6 | ||
|
|
1b5bb875e0 | ||
|
|
09f946d5f4 | ||
|
|
0342242937 | ||
|
|
46463bc2bc | ||
|
|
3b13c0ef3c | ||
|
|
a409273d8d | ||
|
|
270607084e | ||
|
|
4be57313d7 | ||
|
|
4e4cf7648b | ||
|
|
5d084166f4 | ||
|
|
173d744c2e | ||
|
|
d0e0b5d9e6 | ||
|
|
b2475d2b50 | ||
|
|
d0341ef1d0 | ||
|
|
34a55e68b0 | ||
|
|
9a0cd64f68 | ||
|
|
591a4ad423 | ||
|
|
7b479d270a | ||
|
|
58c44f6213 | ||
|
|
b81a0c1eb6 | ||
|
|
37a28df7d8 | ||
|
|
19796e6669 | ||
|
|
87719f322b | ||
|
|
d40c2f5c56 | ||
|
|
48962d1bfa | ||
|
|
42ba48253f | ||
|
|
d55a1aa8b5 | ||
|
|
dbc4ef9618 | ||
|
|
3bf9be1443 | ||
|
|
e2b5ce7b10 | ||
|
|
3d537b4472 | ||
|
|
64c3ca96ce | ||
|
|
71f73d66f2 | ||
|
|
0e4bb317f5 | ||
|
|
583280cf55 | ||
|
|
018f06fd53 | ||
|
|
bf63605d50 | ||
|
|
9643cb7661 | ||
|
|
46669dd0e9 | ||
|
|
299f50f6d9 | ||
|
|
af7f5e60bf | ||
|
|
d869ecbc0c | ||
|
|
d8536765e1 | ||
|
|
9036f3e130 | ||
|
|
ab284c80df | ||
|
|
89b324b299 | ||
|
|
a5f86b13bd | ||
|
|
3cdfc70692 | ||
|
|
29eb1fda9f | ||
|
|
3c00520a09 | ||
|
|
d20aa562ad | ||
|
|
550612a282 | ||
|
|
ef496f3986 | ||
|
|
f4e7fd957d | ||
|
|
4f6051a03b | ||
|
|
670dc5ae9b | ||
|
|
7d6a052e74 | ||
|
|
a7f59ded3f | ||
|
|
67646e4ce0 | ||
|
|
d8346fa24a | ||
|
|
54a41f02a4 | ||
|
|
62d989d40a | ||
|
|
533abe80a1 | ||
|
|
4c8fa249cd | ||
|
|
0551140f90 | ||
|
|
a0bfe7a784 | ||
|
|
45d6bf1eae | ||
|
|
856f0bdf53 | ||
|
|
27831a0d4d | ||
|
|
39fa8a1a14 | ||
|
|
13ca1a7ef9 | ||
|
|
71804a59bb | ||
|
|
37b3ee879d | ||
|
|
57cb80768d | ||
|
|
b6d378d346 | ||
|
|
9ff53fbb1e | ||
|
|
eac976379a | ||
|
|
d444d95f12 | ||
|
|
af61ac38c8 | ||
|
|
2e9f1d2d66 | ||
|
|
2262685465 | ||
|
|
4df37759d7 | ||
|
|
fd9e626593 | ||
|
|
9f890a8c8d | ||
|
|
1e0a146dc4 | ||
|
|
8836bf89a8 | ||
|
|
dcbbc9a545 | ||
|
|
319845f5b1 | ||
|
|
dd7f1399f3 | ||
|
|
8de6ebbe8f | ||
|
|
82c5d424a7 | ||
|
|
bdb15e0845 | ||
|
|
14b5e66192 | ||
|
|
636dd1b86a | ||
|
|
152624a984 | ||
|
|
3b9e95d4e3 | ||
|
|
e64a5f408c | ||
|
|
09e53e898d | ||
|
|
d0f879820b | ||
|
|
02f9843d47 | ||
|
|
1c303e43eb | ||
|
|
cfc7f96f83 | ||
|
|
47c1e0b8ea | ||
|
|
c5958de17b | ||
|
|
b890c73d04 | ||
|
|
13b749974b | ||
|
|
f65ac7275e | ||
|
|
0210ba0ad9 | ||
|
|
d5a9a65209 | ||
|
|
b16c7679b8 | ||
|
|
c4ecc28d66 | ||
|
|
b638c2584b | ||
|
|
4be6a25eb6 | ||
|
|
797bb78d20 | ||
|
|
ee696d233e | ||
|
|
a1d2b2ab59 | ||
|
|
4cb60ca78c | ||
|
|
e2c47bbe92 | ||
|
|
53e333ee22 | ||
|
|
a3181d35b4 | ||
|
|
4948471068 | ||
|
|
ed1acb47c7 | ||
|
|
c73d47b1ec | ||
|
|
b306933f95 | ||
|
|
dba55d2f2a | ||
|
|
d20f0986d9 | ||
|
|
1bc6880dc0 | ||
|
|
8d0e4c6a46 | ||
|
|
1da8543e24 | ||
|
|
29792d915a | ||
|
|
9654a23fd1 | ||
|
|
4f8031a3a4 | ||
|
|
eb6fa1ab7f | ||
|
|
29bf7ad204 | ||
|
|
18e2a07415 | ||
|
|
49d1011b4a | ||
|
|
9b6c9b6c03 | ||
|
|
953ffa6afe | ||
|
|
e0a0f92c1e | ||
|
|
ce493865e7 | ||
|
|
77c9126be4 | ||
|
|
9bb56d4f58 | ||
|
|
908a3be678 | ||
|
|
75dbbbf4ed | ||
|
|
2599a15515 | ||
|
|
15fe102049 | ||
|
|
6f55e51fef | ||
|
|
1bdb004daf | ||
|
|
30e83f45a5 | ||
|
|
7c8a8ee9fc | ||
|
|
8747deeea7 | ||
|
|
63d2a7d0ef | ||
|
|
d89e0a392f | ||
|
|
bb6ce6a355 | ||
|
|
f59fd7205d | ||
|
|
0dd1fa47b8 | ||
|
|
a067ff03b3 | ||
|
|
a652a230c6 | ||
|
|
c2a616fe52 | ||
|
|
1ac573d403 | ||
|
|
80a8ce6f8c | ||
|
|
33e45d3ae9 | ||
|
|
f701a63e27 | ||
|
|
5594d2b284 | ||
|
|
dcd99f7bca | ||
|
|
df086e7ab5 | ||
|
|
5b31d06dcf | ||
|
|
a39564a179 | ||
|
|
18d8162074 | ||
|
|
27a4c47c35 | ||
|
|
1bed502e67 | ||
|
|
d2dd27f99a | ||
|
|
2d83ed6be4 | ||
|
|
de608312a2 | ||
|
|
0b1bd35517 | ||
|
|
44fb3713a4 | ||
|
|
ce2e7e24b1 | ||
|
|
fa574aaa8d | ||
|
|
82aa7012d3 | ||
|
|
0789134948 | ||
|
|
d4bd96d846 | ||
|
|
038b106b37 | ||
|
|
5a7aeb3eba | ||
|
|
a33e70162c | ||
|
|
3245b47635 | ||
|
|
7b965769c9 | ||
|
|
91f62614ab | ||
|
|
c875b4b3ae | ||
|
|
286ceeea3f | ||
|
|
8b026d7e16 | ||
|
|
2f97830f28 | ||
|
|
75827d89c5 | ||
|
|
1899c86494 | ||
|
|
4a7b6cf546 | ||
|
|
0b83c1ad2f | ||
|
|
a9380a8e1b | ||
|
|
b756a5f54c | ||
|
|
762c379c54 | ||
|
|
8f8421e65e | ||
|
|
0e67da5718 | ||
|
|
cf8b1e97bd | ||
|
|
61885b41d9 | ||
|
|
f74cecdc1f | ||
|
|
d8ffd83e1e | ||
|
|
666233136d | ||
|
|
eae5631d8a | ||
|
|
dc614bf733 | ||
|
|
22c9603b3c | ||
|
|
073f9230ba | ||
|
|
3948e83f83 | ||
|
|
12664652e5 | ||
|
|
d1162abf4c | ||
|
|
a93fa72d5a | ||
|
|
3c8c3fe9b8 | ||
|
|
a46f2d118c | ||
|
|
dc0221dabb | ||
|
|
a5e80d2268 | ||
|
|
dbd200cb6c | ||
|
|
aacff632b7 | ||
|
|
e4861b0a68 | ||
|
|
8d1b6da197 | ||
|
|
bf21c1338e | ||
|
|
9452d6667a | ||
|
|
6967773690 | ||
|
|
73b95379fe | ||
|
|
9f89173638 | ||
|
|
77a0201327 | ||
|
|
21c3f81a8b | ||
|
|
035dc4f0b2 | ||
|
|
48fec240cf | ||
|
|
82a6faa8de | ||
|
|
a4bd6d6a07 | ||
|
|
b91743239a | ||
|
|
611dff15bb | ||
|
|
cdd56d88fb | ||
|
|
198c53698d | ||
|
|
3924b72a40 | ||
|
|
6520a4dff8 | ||
|
|
9e62c44bef | ||
|
|
9df01a7477 | ||
|
|
c62eb6cd0e | ||
|
|
8465df483e | ||
|
|
2366356922 | ||
|
|
5655aad59a | ||
|
|
65ddab6ed5 | ||
|
|
d914addbb2 | ||
|
|
b9a4489f3c | ||
|
|
9d3b9c963a | ||
|
|
72d981f361 | ||
|
|
6eeb484c56 | ||
|
|
cd7332b67c | ||
|
|
1a8ff69750 | ||
|
|
991221f0ee | ||
|
|
ef4267e173 | ||
|
|
ba029e3da4 | ||
|
|
4270807e2a | ||
|
|
8456d078f1 | ||
|
|
aa693cebb9 | ||
|
|
219a7ed38f | ||
|
|
2aa90b4f9c | ||
|
|
26f9352411 | ||
|
|
279c51c4a6 | ||
|
|
98f4dd4c87 | ||
|
|
5e1cb21d55 | ||
|
|
aa6e43cc94 | ||
|
|
992e83fdb1 | ||
|
|
cb73c1ebf7 | ||
|
|
09d8f63515 | ||
|
|
4f4cf25027 | ||
|
|
de4e81977a | ||
|
|
10bbfcabcb | ||
|
|
b5d295e57d | ||
|
|
49555c34d8 | ||
|
|
9b6903572a | ||
|
|
f16d64d636 | ||
|
|
bceb5b643a | ||
|
|
7e783feecf | ||
|
|
f9040f65fe | ||
|
|
b73a825ac9 | ||
|
|
67104b96fd | ||
|
|
e2c8fc9662 | ||
|
|
40e46b721f | ||
|
|
30e6f44e44 | ||
|
|
fd06c00fca | ||
|
|
307c234f4a | ||
|
|
d6dda6fbeb | ||
|
|
b4cb8234f1 | ||
|
|
a5fb9fd214 | ||
|
|
3bbbba15b3 |
29
.gitlab-ci.yml
Normal file
29
.gitlab-ci.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
image: registry.gitlab.com/bitfireat/davx5-ose:latest
|
||||
|
||||
before_script:
|
||||
- git submodule update --init --recursive
|
||||
- export GRADLE_USER_HOME=`pwd`/.gradle; chmod +x gradlew
|
||||
|
||||
cache:
|
||||
paths:
|
||||
- .gradle/
|
||||
|
||||
test:
|
||||
script:
|
||||
- (cd /sdk/emulator; ./emulator @test -no-audio -no-window & wait-for-emulator.sh)
|
||||
- ./gradlew app:check app:connectedCheck
|
||||
artifacts:
|
||||
paths:
|
||||
- app/build/outputs/lint-results-debug.html
|
||||
- app/build/reports
|
||||
- build/reports
|
||||
|
||||
pages:
|
||||
script:
|
||||
- ./gradlew app:dokka
|
||||
- mkdir public && mv app/build/dokka public
|
||||
artifacts:
|
||||
paths:
|
||||
- public
|
||||
only:
|
||||
- master-ose
|
||||
13
.gitmodules
vendored
13
.gitmodules
vendored
@@ -1,13 +1,12 @@
|
||||
[submodule "dav4android"]
|
||||
path = dav4android
|
||||
url = https://gitlab.com/bitfireAT/dav4android.git
|
||||
[submodule "dav4jvm"]
|
||||
path = dav4jvm
|
||||
url = https://gitlab.com/bitfireAT/dav4jvm.git
|
||||
[submodule "ical4android"]
|
||||
path = ical4android
|
||||
url = https://gitlab.com/bitfireAT/ical4android.git
|
||||
[submodule "vcard4android"]
|
||||
path = vcard4android
|
||||
url = https://gitlab.com/bitfireAT/vcard4android.git
|
||||
[submodule "MemorizingTrustManager"]
|
||||
path = MemorizingTrustManager
|
||||
url = https://github.com/ge0rg/MemorizingTrustManager
|
||||
ignore = dirty
|
||||
[submodule "cert4android"]
|
||||
path = cert4android
|
||||
url = https://gitlab.com/bitfireAT/cert4android.git
|
||||
|
||||
Submodule MemorizingTrustManager deleted from b6a3d558e4
40
README.md
40
README.md
@@ -1,34 +1,38 @@
|
||||
|
||||
DAVdroid
|
||||

|
||||
|
||||
DAVx⁵
|
||||
========
|
||||
|
||||
Please see the [DAVdroid Web site](https://davdroid.bitfire.at) for
|
||||
comprehensive information about DAVdroid.
|
||||
Please see the [DAVx⁵ Web site](https://www.davx5.com) for
|
||||
comprehensive information about DAVx⁵.
|
||||
|
||||
DAVdroid is licensed under the [GPLv3 License](LICENSE).
|
||||
DAVx⁵ is licensed under the [GPLv3 License](LICENSE).
|
||||
|
||||
News and updates: [@davdroidapp](https://twitter.com/davdroidapp)
|
||||
News and updates: [@davx5app](https://twitter.com/davx5app) on Twitter
|
||||
|
||||
Help and discussion: [DAVdroid forums](https://davdroid.bitfire.at/forums)
|
||||
Help, discussion, feature requests, bug reports: [DAVx⁵ forums](https://www.davx5.com/forums)
|
||||
|
||||
Parts of DAVdroid have been outsourced into these libraries:
|
||||
**If you want to support DAVx⁵, please consider [donating to DAVx⁵](https://www.davx5.com/donate)
|
||||
or [purchasing it](https://www.davx5.com/download).**
|
||||
|
||||
* [dav4android](https://gitlab.com/bitfireAT/dav4android) – WebDAV/CalDav/CardDAV framework
|
||||
Generated KDoc: https://bitfireAT.gitlab.io/davx5-ose/dokka/app/
|
||||
|
||||
Parts of DAVx⁵ have been outsourced into these libraries:
|
||||
|
||||
* [cert4android](https://gitlab.com/bitfireAT/cert4android) – custom certificate management
|
||||
* [dav4jvm](https://gitlab.com/bitfireAT/dav4jvm) – WebDAV/CalDav/CardDAV framework
|
||||
* [ical4android](https://gitlab.com/bitfireAT/ical4android) – iCalendar processing and Calendar Provider access
|
||||
* [vcard4android](https://gitlab.com/bitfireAT/vcard4android) – VCard processing and Contacts Provider access
|
||||
|
||||
[](https://flattr.com/submit/auto?user_id=bitfire&url=https://davdroid.bitfire.at&title=DAVdroid&category=software)
|
||||
* [vcard4android](https://gitlab.com/bitfireAT/vcard4android) – vCard processing and Contacts Provider access
|
||||
|
||||
|
||||
USED THIRD-PARTY LIBRARIES
|
||||
==========================
|
||||
|
||||
Those libraries are used by DAVdroid (alphabetically):
|
||||
Those libraries are used by DAVx⁵ (alphabetically):
|
||||
|
||||
* [Color Picker](https://github.com/jaredrummler/ColorPicker) – [Apache License, Version 2.0](https://github.com/jaredrummler/ColorPicker/LICENSE)
|
||||
* [dnsjava](http://www.xbill.org/dnsjava/) – [BSD License](http://www.xbill.org/dnsjava/dnsjava-current/LICENSE)
|
||||
* [ez-vcard](https://code.google.com/p/ez-vcard/) – [New BSD License](http://opensource.org/licenses/BSD-3-Clause)
|
||||
* [iCal4j](http://ical4j.sourceforge.net/) – [New BSD License](http://sourceforge.net/p/ical4j/ical4j/ci/default/tree/LICENSE)
|
||||
* [MemorizingTrustManager](https://github.com/ge0rg/MemorizingTrustManager) – [MIT License](https://raw.githubusercontent.com/ge0rg/MemorizingTrustManager/master/LICENSE.txt)
|
||||
* [okhttp](https://square.github.io/okhttp/) – [Apache License, Version 2.0](https://square.github.io/okhttp/#license)
|
||||
* [Project Lombok](http://projectlombok.org/) – [MIT License](http://opensource.org/licenses/mit-license.php)
|
||||
* [SLF4J](http://www.slf4j.org/) – [MIT License](http://www.slf4j.org/license.html)
|
||||
* [ez-vcard](https://github.com/mangstadt/ez-vcard) – [New BSD License](http://opensource.org/licenses/BSD-3-Clause)
|
||||
* [iCal4j](https://github.com/ical4j/ical4j) – [New BSD License](http://sourceforge.net/p/ical4j/ical4j/ci/default/tree/LICENSE)
|
||||
* [okhttp](https://square.github.io/okhttp) – [Apache License, Version 2.0](https://square.github.io/okhttp/#license)
|
||||
|
||||
107
app/build.gradle
107
app/build.gradle
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2013 – 2016 Ricki Hirner (bitfire web engineering).
|
||||
* 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
|
||||
@@ -7,27 +7,43 @@
|
||||
*/
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-android-extensions'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'org.jetbrains.dokka-android'
|
||||
|
||||
android {
|
||||
compileSdkVersion 23
|
||||
buildToolsVersion "23.0.3"
|
||||
compileSdkVersion 28
|
||||
buildToolsVersion '28.0.3'
|
||||
|
||||
defaultConfig {
|
||||
applicationId "at.bitfire.davdroid"
|
||||
minSdkVersion 14
|
||||
targetSdkVersion 23
|
||||
|
||||
versionCode 109
|
||||
|
||||
versionCode 279
|
||||
buildConfigField "long", "buildTime", System.currentTimeMillis() + "L"
|
||||
buildConfigField "boolean", "customCerts", "true"
|
||||
|
||||
minSdkVersion 19 // Android 4.4
|
||||
targetSdkVersion 28 // Android 9.0
|
||||
multiDexEnabled true // >64k methods for Android 4.4
|
||||
|
||||
buildConfigField "String", "userAgent", "\"DAVx5\""
|
||||
|
||||
// when using this, make sure that notification icons are real bitmaps
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
dataBinding.enabled = true
|
||||
|
||||
flavorDimensions "distribution"
|
||||
productFlavors {
|
||||
standard {
|
||||
versionName "1.2"
|
||||
}
|
||||
gplay {
|
||||
versionName "1.2-gplay"
|
||||
versionName "2.4-ose"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,46 +59,59 @@ android {
|
||||
|
||||
lintOptions {
|
||||
disable 'GoogleAppIndexingWarning' // we don't need Google indexing, thanks
|
||||
disable 'GradleDependency'
|
||||
disable 'GradleDynamicVersion'
|
||||
disable 'IconColors'
|
||||
disable 'IconLauncherShape'
|
||||
disable 'IconMissingDensityFolder'
|
||||
disable 'ImpliedQuantity'
|
||||
disable 'MissingTranslation'
|
||||
disable 'MissingQuantity'
|
||||
disable 'Recycle' // doesn't understand Lombok's @Cleanup
|
||||
disable 'ImpliedQuantity', 'MissingQuantity' // quantities from Transifex may vary
|
||||
disable 'MissingTranslation', 'ExtraTranslation' // translations from Transifex are not always up to date
|
||||
disable "OnClick" // doesn't recognize Kotlin onClick methods
|
||||
disable 'RtlEnabled'
|
||||
disable 'RtlHardcoded'
|
||||
disable 'Typos'
|
||||
}
|
||||
packagingOptions {
|
||||
exclude 'LICENSE'
|
||||
exclude 'META-INF/LICENSE.txt'
|
||||
exclude 'META-INF/NOTICE.txt'
|
||||
exclude 'META-INF/DEPENDENCIES'
|
||||
exclude 'META-INF/LICENSE'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compile project(':dav4android')
|
||||
compile project(':ical4android')
|
||||
compile project(':vcard4android')
|
||||
implementation project(':cert4android')
|
||||
implementation project(':ical4android')
|
||||
implementation project(':vcard4android')
|
||||
|
||||
compile 'com.android.support:appcompat-v7:23.+'
|
||||
compile 'com.android.support:cardview-v7:23.+'
|
||||
compile 'com.android.support:design:23.+'
|
||||
compile 'com.android.support:preference-v14:23.+'
|
||||
implementation 'androidx.multidex:multidex:2.0.1'
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
|
||||
compile 'com.github.yukuku:ambilwarna:2.0.1'
|
||||
compile project(':MemorizingTrustManager')
|
||||
implementation 'androidx.appcompat:appcompat:1.0.2'
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.0.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-livedata:2.0.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.0.0'
|
||||
implementation 'androidx.preference:preference:1.0.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
||||
implementation 'com.google.android:flexbox:1.1.0'
|
||||
implementation 'com.google.android.material:material:1.0.0'
|
||||
|
||||
compile 'dnsjava:dnsjava:2.1.7'
|
||||
compile 'org.apache.commons:commons-lang3:3.4'
|
||||
compile 'org.apache.commons:commons-collections4:4.1'
|
||||
provided 'org.projectlombok:lombok:1.16.8'
|
||||
implementation(':dav4jvm') {
|
||||
exclude group: 'org.ogce', module: 'xpp3' // Android comes with its own XmlPullParser
|
||||
}
|
||||
implementation 'com.jaredrummler:colorpicker:1.1.0'
|
||||
implementation 'com.mikepenz:aboutlibraries:6.2.3'
|
||||
|
||||
implementation 'com.squareup.okhttp3:logging-interceptor:3.12.2'
|
||||
implementation 'commons-io:commons-io:2.6'
|
||||
implementation 'dnsjava:dnsjava:2.1.8'
|
||||
implementation 'org.apache.commons:commons-collections4:4.3'
|
||||
|
||||
// for tests
|
||||
testCompile 'junit:junit:4.12'
|
||||
testCompile 'com.squareup.okhttp3:mockwebserver:3.3.1'
|
||||
androidTestCompile 'com.squareup.okhttp3:mockwebserver:3.3.1'
|
||||
androidTestImplementation 'androidx.test:runner:1.1.1'
|
||||
androidTestImplementation 'androidx.test:rules:1.1.1'
|
||||
androidTestImplementation 'junit:junit:4.12'
|
||||
androidTestImplementation 'com.squareup.okhttp3:mockwebserver:3.12.2'
|
||||
|
||||
testImplementation 'junit:junit:4.12'
|
||||
testImplementation 'com.squareup.okhttp3:mockwebserver:3.12.2'
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
|
||||
# ProGuard usage for DAVdroid:
|
||||
# shrinking yes (main reason for using ProGuard)
|
||||
# optimization yes
|
||||
# obfuscation no (DAVdroid is open-source)
|
||||
# preverification no
|
||||
# ProGuard usage for DAVx⁵:
|
||||
# shrinking yes (main reason for using ProGuard)
|
||||
# optimization yes
|
||||
# obfuscation no (DAVx⁵ is open-source)
|
||||
# preverification no
|
||||
|
||||
-dontobfuscate
|
||||
|
||||
@@ -12,30 +12,36 @@
|
||||
-allowaccessmodification
|
||||
-dontpreverify
|
||||
|
||||
# Kotlin
|
||||
-dontwarn kotlin.**
|
||||
|
||||
# Apache Commons
|
||||
-dontwarn javax.script.**
|
||||
|
||||
# ez-vcard
|
||||
-dontwarn ezvcard.io.json.** # JSON serializer (for jCards) not used
|
||||
-dontwarn freemarker.** # freemarker templating library (for creating hCards) not used
|
||||
-dontwarn org.jsoup.** # jsoup library (for hCard parsing) not used
|
||||
-dontwarn sun.misc.Perf
|
||||
-keep class ezvcard.property.** { *; } # keep all VCard properties (created at runtime)
|
||||
-dontwarn ezvcard.io.json.** # JSON serializer (for jCards) not used
|
||||
-dontwarn freemarker.** # freemarker templating library (for creating hCards) not used
|
||||
-dontwarn org.jsoup.** # jsoup library (for hCard parsing) not used
|
||||
-keep class ezvcard.property.** { *; } # keep all vCard properties (created at runtime)
|
||||
|
||||
# ical4j: ignore unused dynamic libraries
|
||||
-dontwarn aQute.**
|
||||
-dontwarn groovy.** # Groovy-based ContentBuilder not used
|
||||
-dontwarn groovy.** # Groovy-based ContentBuilder not used
|
||||
-dontwarn javax.cache.** # no JCache support in Android
|
||||
-dontwarn net.fortuna.ical4j.model.**
|
||||
-dontwarn org.codehaus.groovy.**
|
||||
-dontwarn org.apache.commons.logging.** # Commons logging is not available
|
||||
-dontwarn net.fortuna.ical4j.model.** # ignore warnings from Groovy dependency
|
||||
-keep class net.fortuna.ical4j.model.** { *; } # keep all model classes (properties/factories, created at runtime)
|
||||
-dontwarn org.apache.log4j.** # ignore warnings from log4j dependency
|
||||
-keep class net.fortuna.ical4j.** { *; } # keep all model classes (properties/factories, created at runtime)
|
||||
-keep class org.threeten.bp.** { *; } # keep ThreeTen (for time zone processing)
|
||||
|
||||
# okhttp
|
||||
-dontwarn java.nio.file.** # not available on Android
|
||||
-dontwarn javax.annotation.**
|
||||
-dontwarn okio.**
|
||||
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
|
||||
|
||||
# MemorizingTrustManager
|
||||
-dontwarn de.duenndns.ssl.MemorizingTrustManager
|
||||
-dontwarn org.conscrypt.**
|
||||
|
||||
# dnsjava
|
||||
-dontwarn sun.net.spi.nameservice.** # not available on Android
|
||||
-dontwarn sun.net.spi.nameservice.** # not available on Android
|
||||
|
||||
# DAVdroid + libs
|
||||
-keep class at.bitfire.** { *; } # all DAVdroid code is required
|
||||
# DAVx⁵ + libs
|
||||
-keep class at.bitfire.** { *; } # all DAVx⁵ code is required
|
||||
|
||||
1
app/src/.gitignore
vendored
Normal file
1
app/src/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
espressoTest
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import okhttp3.Request
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class HttpClientTest {
|
||||
|
||||
lateinit var server: MockWebServer
|
||||
lateinit var httpClient: HttpClient
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
httpClient = HttpClient.Builder().build()
|
||||
|
||||
server = MockWebServer()
|
||||
server.start(30000)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
httpClient.close()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testCookies() {
|
||||
val url = server.url("/test")
|
||||
|
||||
// set cookie for root path (/) and /test path in first response
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(200)
|
||||
.addHeader("Set-Cookie", "cookie1=1; path=/")
|
||||
.addHeader("Set-Cookie", "cookie2=2")
|
||||
.setBody("Cookie set"))
|
||||
httpClient.okHttpClient.newCall(Request.Builder()
|
||||
.get().url(url)
|
||||
.build()).execute()
|
||||
assertNull(server.takeRequest().getHeader("Cookie"))
|
||||
|
||||
// cookie should be sent with second request
|
||||
// second response lets first cookie expire and overwrites second cookie
|
||||
server.enqueue(MockResponse()
|
||||
.addHeader("Set-Cookie", "cookie1=1a; path=/; Max-Age=0")
|
||||
.addHeader("Set-Cookie", "cookie2=2a")
|
||||
.setResponseCode(200))
|
||||
httpClient.okHttpClient.newCall(Request.Builder()
|
||||
.get().url(url)
|
||||
.build()).execute()
|
||||
assertEquals("cookie2=2; cookie1=1", server.takeRequest().getHeader("Cookie"))
|
||||
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(200))
|
||||
httpClient.okHttpClient.newCall(Request.Builder()
|
||||
.get().url(url)
|
||||
.build()).execute()
|
||||
assertEquals("cookie2=2a", server.takeRequest().getHeader("Cookie"))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import android.os.Build;
|
||||
import android.test.InstrumentationTestCase;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.Socket;
|
||||
|
||||
import javax.net.ssl.SSLSocket;
|
||||
|
||||
import de.duenndns.ssl.MemorizingTrustManager;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
|
||||
public class SSLSocketFactoryCompatTest extends InstrumentationTestCase {
|
||||
|
||||
SSLSocketFactoryCompat factory;
|
||||
MockWebServer server = new MockWebServer();
|
||||
|
||||
@Override
|
||||
protected void setUp() throws Exception {
|
||||
factory = new SSLSocketFactoryCompat(new MemorizingTrustManager(getInstrumentation().getTargetContext().getApplicationContext()));
|
||||
server.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void tearDown() throws Exception {
|
||||
server.shutdown();
|
||||
}
|
||||
|
||||
|
||||
public void testUpgradeTLS() throws IOException {
|
||||
Socket s = factory.createSocket(server.getHostName(), server.getPort());
|
||||
assertTrue(s instanceof SSLSocket);
|
||||
|
||||
SSLSocket ssl = (SSLSocket)s;
|
||||
assertFalse(org.apache.commons.lang3.ArrayUtils.contains(ssl.getEnabledProtocols(), "SSLv3"));
|
||||
assertTrue(org.apache.commons.lang3.ArrayUtils.contains(ssl.getEnabledProtocols(), "TLSv1"));
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 16) {
|
||||
assertTrue(org.apache.commons.lang3.ArrayUtils.contains(ssl.getEnabledProtocols(), "TLSv1.1"));
|
||||
assertTrue(org.apache.commons.lang3.ArrayUtils.contains(ssl.getEnabledProtocols(), "TLSv1.2"));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2016 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model;
|
||||
|
||||
import android.content.ContentValues;
|
||||
|
||||
import junit.framework.TestCase;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import at.bitfire.dav4android.DavResource;
|
||||
import at.bitfire.dav4android.exception.DavException;
|
||||
import at.bitfire.dav4android.exception.HttpException;
|
||||
import at.bitfire.dav4android.property.ResourceType;
|
||||
import at.bitfire.davdroid.HttpClient;
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections;
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
|
||||
public class CollectionInfoTest extends TestCase {
|
||||
|
||||
MockWebServer server = new MockWebServer();
|
||||
|
||||
public void testFromDavResource() throws IOException, HttpException, DavException {
|
||||
// r/w address book
|
||||
server.enqueue(new 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>"));
|
||||
|
||||
DavResource dav = new DavResource(HttpClient.create(), server.url("/"));
|
||||
dav.propfind(0, ResourceType.NAME);
|
||||
CollectionInfo info = CollectionInfo.fromDavResource(dav);
|
||||
assertEquals(CollectionInfo.Type.ADDRESS_BOOK, info.type);
|
||||
assertFalse(info.readOnly);
|
||||
assertEquals("My Contacts", info.displayName);
|
||||
assertEquals("My Contacts Description", info.description);
|
||||
|
||||
// read-only calendar, no display name
|
||||
server.enqueue(new 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>"));
|
||||
|
||||
dav = new DavResource(HttpClient.create(), server.url("/"));
|
||||
dav.propfind(0, ResourceType.NAME);
|
||||
info = CollectionInfo.fromDavResource(dav);
|
||||
assertEquals(CollectionInfo.Type.CALENDAR, info.type);
|
||||
assertTrue(info.readOnly);
|
||||
assertNull(info.displayName);
|
||||
assertEquals("My Calendar", info.description);
|
||||
assertEquals(0xFFFF0000, (int)info.color);
|
||||
assertEquals("tzdata", info.timeZone);
|
||||
assertTrue(info.supportsVEVENT);
|
||||
assertTrue(info.supportsVTODO);
|
||||
}
|
||||
|
||||
public void testFromDB() {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(Collections.ID, 1);
|
||||
values.put(Collections.SERVICE_ID, 1);
|
||||
values.put(Collections.URL, "http://example.com");
|
||||
values.put(Collections.READ_ONLY, 1);
|
||||
values.put(Collections.DISPLAY_NAME, "display name");
|
||||
values.put(Collections.DESCRIPTION, "description");
|
||||
values.put(Collections.COLOR, 0xFFFF0000);
|
||||
values.put(Collections.TIME_ZONE, "tzdata");
|
||||
values.put(Collections.SUPPORTS_VEVENT, 1);
|
||||
values.put(Collections.SUPPORTS_VTODO, 1);
|
||||
values.put(Collections.SYNC, 1);
|
||||
|
||||
CollectionInfo info = CollectionInfo.fromDB(values);
|
||||
assertEquals(1, info.id);
|
||||
assertEquals(1, (long)info.serviceID);
|
||||
assertEquals("http://example.com", info.url);
|
||||
assertTrue(info.readOnly);
|
||||
assertEquals("display name", info.displayName);
|
||||
assertEquals("description", info.description);
|
||||
assertEquals(0xFFFF0000, (int)info.color);
|
||||
assertEquals("tzdata", info.timeZone);
|
||||
assertTrue(info.supportsVEVENT);
|
||||
assertTrue(info.supportsVTODO);
|
||||
assertTrue(info.selected);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import android.content.ContentValues
|
||||
import androidx.test.filters.SmallTest
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.property.ResourceType
|
||||
import at.bitfire.davdroid.HttpClient
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class CollectionInfoTest {
|
||||
|
||||
private lateinit var httpClient: HttpClient
|
||||
private val server = MockWebServer()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
httpClient = HttpClient.Builder().build()
|
||||
}
|
||||
|
||||
@After
|
||||
fun shutDown() {
|
||||
httpClient.close()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testFromDavResource() {
|
||||
// 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>"))
|
||||
|
||||
var info: CollectionInfo? = null
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = CollectionInfo(response)
|
||||
}
|
||||
assertEquals(CollectionInfo.Type.ADDRESS_BOOK, info?.type)
|
||||
assertTrue(info!!.privWriteContent)
|
||||
assertTrue(info!!.privUnbind)
|
||||
assertEquals("My Contacts", info?.displayName)
|
||||
assertEquals("My Contacts Description", info?.description)
|
||||
|
||||
// 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>"))
|
||||
|
||||
info = null
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = CollectionInfo(response)
|
||||
}
|
||||
assertEquals(CollectionInfo.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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFromDB() {
|
||||
val values = ContentValues()
|
||||
values.put(Collections.ID, 1)
|
||||
values.put(Collections.SERVICE_ID, 1)
|
||||
values.put(Collections.TYPE, CollectionInfo.Type.CALENDAR.name)
|
||||
values.put(Collections.URL, "http://example.com")
|
||||
values.put(Collections.PRIV_WRITE_CONTENT, 0)
|
||||
values.put(Collections.PRIV_UNBIND, 0)
|
||||
values.put(Collections.DISPLAY_NAME, "display name")
|
||||
values.put(Collections.DESCRIPTION, "description")
|
||||
values.put(Collections.COLOR, 0xFFFF0000)
|
||||
values.put(Collections.TIME_ZONE, "tzdata")
|
||||
values.put(Collections.SUPPORTS_VEVENT, 1)
|
||||
values.put(Collections.SUPPORTS_VTODO, 1)
|
||||
values.put(Collections.SYNC, 1)
|
||||
|
||||
val info = CollectionInfo(values)
|
||||
assertEquals(CollectionInfo.Type.CALENDAR, info.type)
|
||||
assertEquals(1.toLong(), info.id)
|
||||
assertEquals(1.toLong(), info.serviceID)
|
||||
assertEquals(HttpUrl.parse("http://example.com/"), info.url)
|
||||
assertFalse(info.privWriteContent)
|
||||
assertFalse(info.privUnbind)
|
||||
assertEquals("display name", info.displayName)
|
||||
assertEquals("description", info.description)
|
||||
assertEquals(0xFFFF0000.toInt(), info.color)
|
||||
assertEquals("tzdata", info.timeZone)
|
||||
assertTrue(info.supportsVEVENT)
|
||||
assertTrue(info.supportsVTODO)
|
||||
assertTrue(info.selected)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultsSettingsProviderTest {
|
||||
|
||||
private val provider: SettingsProvider = DefaultsProvider()
|
||||
|
||||
@Test
|
||||
fun testHas() {
|
||||
assertEquals(Pair(false, true), provider.has("notExisting"))
|
||||
assertEquals(Pair(true, true), provider.has(Settings.OVERRIDE_PROXY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGet() {
|
||||
assertEquals(Pair("localhost", true), provider.getString(Settings.OVERRIDE_PROXY_HOST))
|
||||
assertEquals(Pair(8118, true), provider.getInt(Settings.OVERRIDE_PROXY_PORT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPutRemove() {
|
||||
assertEquals(Pair(false, true), provider.isWritable(Settings.OVERRIDE_PROXY))
|
||||
assertFalse(provider.putBoolean(Settings.OVERRIDE_PROXY, true))
|
||||
assertFalse(provider.remove(Settings.OVERRIDE_PROXY))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class SettingsTest {
|
||||
|
||||
lateinit var settings: Settings
|
||||
|
||||
@Before
|
||||
fun initialize() {
|
||||
settings = Settings.getInstance(InstrumentationRegistry.getInstrumentation().targetContext)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHas() {
|
||||
assertFalse(settings.has("notExisting"))
|
||||
|
||||
// provided by DefaultsProvider
|
||||
assertTrue(settings.has(Settings.OVERRIDE_PROXY))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2016 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui.setup;
|
||||
|
||||
import android.test.InstrumentationTestCase;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
|
||||
import at.bitfire.dav4android.DavResource;
|
||||
import at.bitfire.dav4android.exception.DavException;
|
||||
import at.bitfire.dav4android.exception.HttpException;
|
||||
import at.bitfire.dav4android.property.AddressbookHomeSet;
|
||||
import at.bitfire.dav4android.property.ResourceType;
|
||||
import at.bitfire.davdroid.App;
|
||||
import at.bitfire.davdroid.HttpClient;
|
||||
import at.bitfire.davdroid.ui.setup.DavResourceFinder;
|
||||
import at.bitfire.davdroid.ui.setup.DavResourceFinder.Configuration.ServiceInfo;
|
||||
import at.bitfire.davdroid.ui.setup.LoginCredentials;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.mockwebserver.Dispatcher;
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import okhttp3.mockwebserver.RecordedRequest;
|
||||
|
||||
public class DavResourceFinderTest extends InstrumentationTestCase {
|
||||
|
||||
MockWebServer server = new MockWebServer();
|
||||
|
||||
DavResourceFinder finder;
|
||||
OkHttpClient client;
|
||||
LoginCredentials credentials;
|
||||
|
||||
private static final String
|
||||
PATH_NO_DAV = "/nodav",
|
||||
|
||||
PATH_CALDAV = "/caldav",
|
||||
PATH_CARDDAV = "/carddav",
|
||||
PATH_CALDAV_AND_CARDDAV = "/both-caldav-carddav",
|
||||
|
||||
SUBPATH_PRINCIPAL = "/principal",
|
||||
SUBPATH_ADDRESSBOOK_HOMESET = "/addressbooks",
|
||||
SUBPATH_ADDRESSBOOK = "/addressbooks/private-contacts";
|
||||
|
||||
@Override
|
||||
protected void setUp() throws Exception {
|
||||
server.setDispatcher(new TestDispatcher());
|
||||
server.start();
|
||||
|
||||
credentials = new LoginCredentials(URI.create("/"), "mock", "12345", true);
|
||||
finder = new DavResourceFinder(getInstrumentation().getContext(), credentials);
|
||||
|
||||
client = HttpClient.create();
|
||||
client = HttpClient.addAuthentication(client, credentials.userName, credentials.password, credentials.authPreemptive);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void tearDown() throws Exception {
|
||||
server.shutdown();
|
||||
}
|
||||
|
||||
|
||||
public void testRememberIfAddressBookOrHomeset() throws IOException, HttpException, DavException {
|
||||
ServiceInfo info;
|
||||
|
||||
// before dav.propfind(), no info is available
|
||||
DavResource dav = new DavResource(client, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL));
|
||||
finder.rememberIfAddressBookOrHomeset(dav, info = new ServiceInfo());
|
||||
assertEquals(0, info.collections.size());
|
||||
assertEquals(0, info.homeSets.size());
|
||||
|
||||
// recognize home set
|
||||
dav.propfind(0, AddressbookHomeSet.NAME);
|
||||
finder.rememberIfAddressBookOrHomeset(dav, info = new ServiceInfo());
|
||||
assertEquals(0, info.collections.size());
|
||||
assertEquals(1, info.homeSets.size());
|
||||
assertEquals(server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET + "/").uri(), info.homeSets.iterator().next());
|
||||
|
||||
// recognize address book
|
||||
dav = new DavResource(client, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK));
|
||||
dav.propfind(0, ResourceType.NAME);
|
||||
finder.rememberIfAddressBookOrHomeset(dav, info = new ServiceInfo());
|
||||
assertEquals(1, info.collections.size());
|
||||
assertEquals(server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK + "/").uri(), info.collections.keySet().iterator().next());
|
||||
assertEquals(0, info.homeSets.size());
|
||||
}
|
||||
|
||||
public void testProvidesService() throws IOException {
|
||||
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV));
|
||||
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV));
|
||||
|
||||
assertTrue(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV));
|
||||
assertFalse(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV));
|
||||
|
||||
assertTrue(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV));
|
||||
assertFalse(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV));
|
||||
|
||||
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CALDAV));
|
||||
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CARDDAV));
|
||||
}
|
||||
|
||||
public void testGetCurrentUserPrincipal() throws IOException, HttpException, DavException {
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV));
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV));
|
||||
|
||||
assertEquals(
|
||||
server.url(PATH_CALDAV + SUBPATH_PRINCIPAL).uri(),
|
||||
finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV)
|
||||
);
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV));
|
||||
|
||||
assertEquals(
|
||||
server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL).uri(),
|
||||
finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV)
|
||||
);
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV));
|
||||
}
|
||||
|
||||
|
||||
// mock server
|
||||
|
||||
public class TestDispatcher extends Dispatcher {
|
||||
|
||||
@Override
|
||||
public MockResponse dispatch(RecordedRequest rq) throws InterruptedException {
|
||||
if (!checkAuth(rq))
|
||||
return new MockResponse().setResponseCode(401);
|
||||
|
||||
String path = rq.getPath();
|
||||
|
||||
if ("OPTIONS".equalsIgnoreCase(rq.getMethod())) {
|
||||
String dav = null;
|
||||
if (path.startsWith(PATH_CALDAV))
|
||||
dav = "calendar-access";
|
||||
else if (path.startsWith(PATH_CARDDAV))
|
||||
dav = "addressbook";
|
||||
else if (path.startsWith(PATH_CALDAV_AND_CARDDAV))
|
||||
dav = "calendar-access, addressbook";
|
||||
MockResponse response = new MockResponse().setResponseCode(200);
|
||||
if (dav != null)
|
||||
response.addHeader("DAV", dav);
|
||||
return response;
|
||||
|
||||
} else if ("PROPFIND".equalsIgnoreCase(rq.getMethod())) {
|
||||
String props = null;
|
||||
switch (path) {
|
||||
case PATH_CALDAV:
|
||||
case PATH_CARDDAV:
|
||||
props = "<current-user-principal><href>" + path + SUBPATH_PRINCIPAL + "</href></current-user-principal>";
|
||||
break;
|
||||
|
||||
case PATH_CARDDAV + SUBPATH_PRINCIPAL:
|
||||
props = "<CARD:addressbook-home-set>" +
|
||||
" <href>" + PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET + "</href>" +
|
||||
"</CARD:addressbook-home-set>";
|
||||
break;
|
||||
case PATH_CARDDAV + SUBPATH_ADDRESSBOOK:
|
||||
props = "<resourcetype>" +
|
||||
" <collection/>" +
|
||||
" <CARD:addressbook/>" +
|
||||
"</resourcetype>";
|
||||
break;
|
||||
}
|
||||
App.log.info("Sending props: " + props);
|
||||
return new MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
|
||||
"<response>" +
|
||||
" <href>" + rq.getPath() + "</href>" +
|
||||
" <propstat><prop>" + props + "</prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>");
|
||||
}
|
||||
|
||||
return new MockResponse().setResponseCode(404);
|
||||
}
|
||||
|
||||
private boolean checkAuth(RecordedRequest rq) {
|
||||
return "Basic bW9jazoxMjM0NQ==".equals(rq.getHeader("Authorization"));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui.setup
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.filters.SmallTest
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.property.AddressbookHomeSet
|
||||
import at.bitfire.dav4jvm.property.ResourceType
|
||||
import at.bitfire.davdroid.HttpClient
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.Credentials
|
||||
import at.bitfire.davdroid.ui.setup.DavResourceFinder.Configuration.ServiceInfo
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.net.URI
|
||||
|
||||
class DavResourceFinderTest {
|
||||
|
||||
companion object {
|
||||
private const val PATH_NO_DAV = "/nodav"
|
||||
private const val PATH_CALDAV = "/caldav"
|
||||
private const val PATH_CARDDAV = "/carddav"
|
||||
private const val PATH_CALDAV_AND_CARDDAV = "/both-caldav-carddav"
|
||||
|
||||
private const val SUBPATH_PRINCIPAL = "/principal"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET = "/addressbooks"
|
||||
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/private-contacts"
|
||||
}
|
||||
|
||||
val server = MockWebServer()
|
||||
|
||||
lateinit var finder: DavResourceFinder
|
||||
lateinit var client: HttpClient
|
||||
lateinit var loginModel: LoginModel
|
||||
|
||||
@Before
|
||||
fun initServerAndClient() {
|
||||
server.setDispatcher(TestDispatcher())
|
||||
server.start()
|
||||
|
||||
val application = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application
|
||||
loginModel = LoginModel(application)
|
||||
loginModel.baseURI = URI.create("/")
|
||||
loginModel.credentials = Credentials("mock", "12345")
|
||||
|
||||
finder = DavResourceFinder(InstrumentationRegistry.getInstrumentation().targetContext, loginModel)
|
||||
client = HttpClient.Builder()
|
||||
.addAuthentication(null, loginModel.credentials!!)
|
||||
.build()
|
||||
}
|
||||
|
||||
@After
|
||||
fun stopServer() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testRememberIfAddressBookOrHomeset() {
|
||||
// recognize home set
|
||||
var info = ServiceInfo()
|
||||
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL))
|
||||
.propfind(0, AddressbookHomeSet.NAME) { response, _ ->
|
||||
finder.scanCardDavResponse(response, info)
|
||||
}
|
||||
assertEquals(0, info.collections.size)
|
||||
assertEquals(1, info.homeSets.size)
|
||||
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET/"), info.homeSets.first())
|
||||
|
||||
// recognize address book
|
||||
info = ServiceInfo()
|
||||
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
finder.scanCardDavResponse(response, info)
|
||||
}
|
||||
assertEquals(1, info.collections.size)
|
||||
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), info.collections.keys.first())
|
||||
assertEquals(0, info.homeSets.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testProvidesService() {
|
||||
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV))
|
||||
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV))
|
||||
|
||||
assertTrue(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV))
|
||||
assertFalse(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV))
|
||||
|
||||
assertTrue(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV))
|
||||
assertFalse(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV))
|
||||
|
||||
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CALDAV))
|
||||
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CARDDAV))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetCurrentUserPrincipal() {
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV))
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV))
|
||||
|
||||
assertEquals(
|
||||
server.url(PATH_CALDAV + SUBPATH_PRINCIPAL),
|
||||
finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV)
|
||||
)
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV))
|
||||
|
||||
assertEquals(
|
||||
server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL),
|
||||
finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV)
|
||||
)
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV))
|
||||
}
|
||||
|
||||
|
||||
// mock server
|
||||
|
||||
class TestDispatcher: Dispatcher() {
|
||||
|
||||
override fun dispatch(rq: RecordedRequest): MockResponse {
|
||||
if (!checkAuth(rq)) {
|
||||
val authenticate = MockResponse().setResponseCode(401)
|
||||
authenticate.setHeader("WWW-Authenticate", "Basic realm=\"test\"")
|
||||
return authenticate
|
||||
}
|
||||
|
||||
val path = rq.path
|
||||
|
||||
if (rq.method.equals("OPTIONS", true)) {
|
||||
val dav = when {
|
||||
path.startsWith(PATH_CALDAV) -> "calendar-access"
|
||||
path.startsWith(PATH_CARDDAV) -> "addressbook"
|
||||
path.startsWith(PATH_CALDAV_AND_CARDDAV) -> "calendar-access, addressbook"
|
||||
else -> null
|
||||
}
|
||||
val response = MockResponse().setResponseCode(200)
|
||||
if (dav != null)
|
||||
response.addHeader("DAV", dav)
|
||||
return response
|
||||
} else if (rq.method.equals("PROPFIND", true)) {
|
||||
val props: String?
|
||||
when (path) {
|
||||
PATH_CALDAV,
|
||||
PATH_CARDDAV ->
|
||||
props = "<current-user-principal><href>$path$SUBPATH_PRINCIPAL</href></current-user-principal>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
|
||||
props = "<CARD:addressbook-home-set>" +
|
||||
" <href>$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET</href>" +
|
||||
"</CARD:addressbook-home-set>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK ->
|
||||
props = "<resourcetype>" +
|
||||
" <collection/>" +
|
||||
" <CARD:addressbook/>" +
|
||||
"</resourcetype>"
|
||||
|
||||
else -> props = null
|
||||
}
|
||||
Logger.log.info("Sending props: $props")
|
||||
return MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
|
||||
"<response>" +
|
||||
" <href>${rq.path}</href>" +
|
||||
" <propstat><prop>$props</prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>")
|
||||
}
|
||||
|
||||
return MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
private fun checkAuth(rq: RecordedRequest) =
|
||||
rq.getHeader("Authorization") == "Basic bW9jazoxMjM0NQ=="
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright © 2013 – 2016 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
|
||||
-->
|
||||
|
||||
<menu xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<item
|
||||
android:id="@+id/nav_about"
|
||||
android:icon="@drawable/ic_info_dark"
|
||||
android:title="@string/navigation_drawer_about"/>
|
||||
<item
|
||||
android:id="@+id/nav_app_settings"
|
||||
android:icon="@drawable/ic_settings_dark"
|
||||
android:title="@string/navigation_drawer_settings"/>
|
||||
|
||||
<item android:title="@string/navigation_drawer_news_updates">
|
||||
<menu>
|
||||
<item
|
||||
android:id="@+id/nav_twitter"
|
||||
android:icon="@drawable/twitter"
|
||||
android:title="\@davdroidapp"
|
||||
tools:ignore="HardcodedText"/>
|
||||
</menu>
|
||||
</item>
|
||||
|
||||
<item android:title="@string/navigation_drawer_external_links">
|
||||
<menu>
|
||||
<item
|
||||
android:id="@+id/nav_website"
|
||||
android:icon="@drawable/ic_home_dark"
|
||||
android:title="@string/navigation_drawer_website"/>
|
||||
<item
|
||||
android:id="@+id/nav_faq"
|
||||
android:icon="@drawable/ic_help_dark"
|
||||
android:title="@string/navigation_drawer_faq"/>
|
||||
<item
|
||||
android:id="@+id/nav_forums"
|
||||
android:icon="@drawable/ic_forum_dark"
|
||||
android:title="@string/navigation_drawer_forums"/>
|
||||
<item
|
||||
android:id="@+id/nav_donate"
|
||||
android:icon="@drawable/ic_attach_money_dark"
|
||||
android:title="(entry disabled)"
|
||||
android:visible="false"
|
||||
tools:ignore="HardcodedText"/>
|
||||
</menu>
|
||||
</item>
|
||||
|
||||
</menu>
|
||||
@@ -1,11 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2013 – 2015 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
|
||||
-->
|
||||
<manifest package="at.bitfire.davdroid"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
@@ -15,28 +8,15 @@
|
||||
<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.GET_ACCOUNTS"/>
|
||||
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS"/>
|
||||
<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.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||
|
||||
<!-- legacy permissions -->
|
||||
<uses-permission
|
||||
android:name="android.permission.AUTHENTICATE_ACCOUNTS"
|
||||
android:maxSdkVersion="22"
|
||||
tools:ignore="UnusedAttribute"/>
|
||||
<!--
|
||||
for writing external log files; permission only required for SDK <= 18 because since then,
|
||||
writing to app-private directory doesn't require extra permissions
|
||||
-->
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="18"
|
||||
tools:ignore="UnusedAttribute"/>
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="18"
|
||||
tools:ignore="UnusedAttribute"/>
|
||||
<!-- 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 -->
|
||||
@@ -46,101 +26,24 @@
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR"/>
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR"/>
|
||||
|
||||
<!-- android.permission-group.LOCATION -->
|
||||
<!-- required since Android 8.1 to get the WiFi name (for "sync in Wifi only" feature) -->
|
||||
<uses-permission-sdk-23 android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
|
||||
<!-- ical4android declares task access permissions -->
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="true"
|
||||
android:fullBackupContent="false"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppTheme"
|
||||
android:theme="@style/AppThemeExt"
|
||||
tools:ignore="UnusedAttribute">
|
||||
|
||||
<receiver
|
||||
android:name=".App$ReinitLoggingReceiver"
|
||||
android:exported="false"
|
||||
android:process=":sync">
|
||||
<intent-filter>
|
||||
<action android:name="at.bitfire.davdroid.REINIT_LOGGER"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<receiver
|
||||
android:name=".AccountSettings$AppUpdatedReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.PACKAGE_REPLACED" />
|
||||
<data android:scheme="package" android:path="at.bitfire.davdroid" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<service android:name=".DavService"/>
|
||||
|
||||
<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.ContactsSyncAdapterService"
|
||||
android:exported="true"
|
||||
android:process=":sync"
|
||||
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>
|
||||
<service
|
||||
android:name=".syncadapter.CalendarsSyncAdapterService"
|
||||
android:exported="true"
|
||||
android:process=":sync"
|
||||
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.TasksSyncAdapterService"
|
||||
android:exported="true"
|
||||
android:process=":sync"
|
||||
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"/>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".DavService"
|
||||
android:enabled="true">
|
||||
</service>
|
||||
<receiver
|
||||
android:name=".AccountsChangedReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.LOGIN_ACCOUNTS_CHANGED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<activity
|
||||
android:name=".ui.AccountsActivity"
|
||||
android:label="@string/app_name"
|
||||
@@ -153,18 +56,15 @@
|
||||
|
||||
<activity
|
||||
android:name=".ui.AboutActivity"
|
||||
android:label="@string/app_name"
|
||||
android:label="@string/navigation_drawer_about"
|
||||
android:theme="@style/AppTheme.NoActionBar"
|
||||
android:parentActivityName=".ui.AccountsActivity"/>
|
||||
|
||||
<activity
|
||||
android:name=".ui.AppSettingsActivity"
|
||||
android:label="@string/app_settings"
|
||||
android:parentActivityName=".ui.AccountsActivity"/>
|
||||
|
||||
<activity android:name=".ui.PermissionsActivity"
|
||||
android:label="@string/permissions_title"
|
||||
android:parentActivityName=".ui.AccountsActivity"/>
|
||||
android:parentActivityName=".ui.AccountsActivity"
|
||||
android:exported="true"/>
|
||||
|
||||
<activity
|
||||
android:name=".ui.setup.LoginActivity"
|
||||
@@ -187,14 +87,102 @@
|
||||
|
||||
<activity
|
||||
android:name=".ui.DebugInfoActivity"
|
||||
android:parentActivityName=".ui.AppSettingsActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/debug_info_title">
|
||||
</activity>
|
||||
<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>
|
||||
|
||||
<!-- MemorizingTrustManager -->
|
||||
<activity
|
||||
android:name="de.duenndns.ssl.MemorizingActivity"
|
||||
android:theme="@android:style/Theme.Holo.Light.Dialog.NoActionBar"/>
|
||||
<!-- 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.TasksSyncAdapterService"
|
||||
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"/>
|
||||
</service>
|
||||
|
||||
<!-- account type "DAVx⁵ Address book" -->
|
||||
<service
|
||||
android:name=".syncadapter.NullAuthenticatorService"
|
||||
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_address_book"/>
|
||||
</service>
|
||||
<provider
|
||||
android:authorities="@string/address_books_authority"
|
||||
android:exported="false"
|
||||
android:label="@string/address_books_authority_title"
|
||||
android:name=".syncadapter.AddressBookProvider"
|
||||
android:multiprocess="false"/>
|
||||
<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>
|
||||
|
||||
</application>
|
||||
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
<h3>Apache License, Version 2.0, January 2004</h3>
|
||||
<p><a href="http://www.apache.org/licenses/">http://www.apache.org/licenses/</a> </p>
|
||||
<p>TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION</p>
|
||||
<p><strong><a name="definitions">1. Definitions</a></strong>.</p>
|
||||
<p>"License" shall mean the terms and conditions for use, reproduction, and
|
||||
distribution as defined by Sections 1 through 9 of this document.</p>
|
||||
<p>"Licensor" shall mean the copyright owner or entity authorized by the
|
||||
copyright owner that is granting the License.</p>
|
||||
<p>"Legal Entity" shall mean the union of the acting entity and all other
|
||||
entities that control, are controlled by, or are under common control with
|
||||
that entity. For the purposes of this definition, "control" means (i) the
|
||||
power, direct or indirect, to cause the direction or management of such
|
||||
entity, whether by contract or otherwise, or (ii) ownership of fifty
|
||||
percent (50%) or more of the outstanding shares, or (iii) beneficial
|
||||
ownership of such entity.</p>
|
||||
<p>"You" (or "Your") shall mean an individual or Legal Entity exercising
|
||||
permissions granted by this License.</p>
|
||||
<p>"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation source,
|
||||
and configuration files.</p>
|
||||
<p>"Object" form shall mean any form resulting from mechanical transformation
|
||||
or translation of a Source form, including but not limited to compiled
|
||||
object code, generated documentation, and conversions to other media types.</p>
|
||||
<p>"Work" shall mean the work of authorship, whether in Source or Object form,
|
||||
made available under the License, as indicated by a copyright notice that
|
||||
is included in or attached to the work (an example is provided in the
|
||||
Appendix below).</p>
|
||||
<p>"Derivative Works" shall mean any work, whether in Source or Object form,
|
||||
that is based on (or derived from) the Work and for which the editorial
|
||||
revisions, annotations, elaborations, or other modifications represent, as
|
||||
a whole, an original work of authorship. For the purposes of this License,
|
||||
Derivative Works shall not include works that remain separable from, or
|
||||
merely link (or bind by name) to the interfaces of, the Work and Derivative
|
||||
Works thereof.</p>
|
||||
<p>"Contribution" shall mean any work of authorship, including the original
|
||||
version of the Work and any modifications or additions to that Work or
|
||||
Derivative Works thereof, that is intentionally submitted to Licensor for
|
||||
inclusion in the Work by the copyright owner or by an individual or Legal
|
||||
Entity authorized to submit on behalf of the copyright owner. For the
|
||||
purposes of this definition, "submitted" means any form of electronic,
|
||||
verbal, or written communication sent to the Licensor or its
|
||||
representatives, including but not limited to communication on electronic
|
||||
mailing lists, source code control systems, and issue tracking systems that
|
||||
are managed by, or on behalf of, the Licensor for the purpose of discussing
|
||||
and improving the Work, but excluding communication that is conspicuously
|
||||
marked or otherwise designated in writing by the copyright owner as "Not a
|
||||
Contribution."</p>
|
||||
<p>"Contributor" shall mean Licensor and any individual or Legal Entity on
|
||||
behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.</p>
|
||||
<p><strong><a name="copyright">2. Grant of Copyright License</a></strong>. Subject to the
|
||||
terms and conditions of this License, each Contributor hereby grants to You
|
||||
a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of, publicly
|
||||
display, publicly perform, sublicense, and distribute the Work and such
|
||||
Derivative Works in Source or Object form.</p>
|
||||
<p><strong><a name="patent">3. Grant of Patent License</a></strong>. Subject to the terms
|
||||
and conditions of this License, each Contributor hereby grants to You a
|
||||
perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made, use,
|
||||
offer to sell, sell, import, and otherwise transfer the Work, where such
|
||||
license applies only to those patent claims licensable by such Contributor
|
||||
that are necessarily infringed by their Contribution(s) alone or by
|
||||
combination of their Contribution(s) with the Work to which such
|
||||
Contribution(s) was submitted. If You institute patent litigation against
|
||||
any entity (including a cross-claim or counterclaim in a lawsuit) alleging
|
||||
that the Work or a Contribution incorporated within the Work constitutes
|
||||
direct or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate as of the
|
||||
date such litigation is filed.</p>
|
||||
<p><strong><a name="redistribution">4. Redistribution</a></strong>. You may reproduce and
|
||||
distribute copies of the Work or Derivative Works thereof in any medium,
|
||||
with or without modifications, and in Source or Object form, provided that
|
||||
You meet the following conditions:</p>
|
||||
|
||||
<p>a. You must give any other recipients of the Work or Derivative Works a
|
||||
copy of this License; and</p>
|
||||
|
||||
<p>b. You must cause any modified files to carry prominent notices stating
|
||||
that You changed the files; and</p>
|
||||
|
||||
<p>c. You must retain, in the Source form of any Derivative Works that You
|
||||
distribute, all copyright, patent, trademark, and attribution notices from
|
||||
the Source form of the Work, excluding those notices that do not pertain to
|
||||
any part of the Derivative Works; and</p>
|
||||
|
||||
<p>d. If the Work includes a "NOTICE" text file as part of its distribution,
|
||||
then any Derivative Works that You distribute must include a readable copy
|
||||
of the attribution notices contained within such NOTICE file, excluding
|
||||
those notices that do not pertain to any part of the Derivative Works, in
|
||||
at least one of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or documentation,
|
||||
if provided along with the Derivative Works; or, within a display generated
|
||||
by the Derivative Works, if and wherever such third-party notices normally
|
||||
appear. The contents of the NOTICE file are for informational purposes only
|
||||
and do not modify the License. You may add Your own attribution notices
|
||||
within Derivative Works that You distribute, alongside or as an addendum to
|
||||
the NOTICE text from the Work, provided that such additional attribution
|
||||
notices cannot be construed as modifying the License.
|
||||
<br/>
|
||||
<br/>
|
||||
You may add Your own copyright statement to Your modifications and may
|
||||
provide additional or different license terms and conditions for use,
|
||||
reproduction, or distribution of Your modifications, or for any such
|
||||
Derivative Works as a whole, provided Your use, reproduction, and
|
||||
distribution of the Work otherwise complies with the conditions stated in
|
||||
this License.
|
||||
</p>
|
||||
|
||||
<p><strong><a name="contributions">5. Submission of Contributions</a></strong>. Unless You
|
||||
explicitly state otherwise, any Contribution intentionally submitted for
|
||||
inclusion in the Work by You to the Licensor shall be under the terms and
|
||||
conditions of this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify the
|
||||
terms of any separate license agreement you may have executed with Licensor
|
||||
regarding such Contributions.</p>
|
||||
<p><strong><a name="trademarks">6. Trademarks</a></strong>. This License does not grant
|
||||
permission to use the trade names, trademarks, service marks, or product
|
||||
names of the Licensor, except as required for reasonable and customary use
|
||||
in describing the origin of the Work and reproducing the content of the
|
||||
NOTICE file.</p>
|
||||
<p><strong><a name="no-warranty">7. Disclaimer of Warranty</a></strong>. Unless required by
|
||||
applicable law or agreed to in writing, Licensor provides the Work (and
|
||||
each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT
|
||||
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including,
|
||||
without limitation, any warranties or conditions of TITLE,
|
||||
NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You
|
||||
are solely responsible for determining the appropriateness of using or
|
||||
redistributing the Work and assume any risks associated with Your exercise
|
||||
of permissions under this License.</p>
|
||||
<p><strong><a name="no-liability">8. Limitation of Liability</a></strong>. In no event and
|
||||
under no legal theory, whether in tort (including negligence), contract, or
|
||||
otherwise, unless required by applicable law (such as deliberate and
|
||||
grossly negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a result
|
||||
of this License or out of the use or inability to use the Work (including
|
||||
but not limited to damages for loss of goodwill, work stoppage, computer
|
||||
failure or malfunction, or any and all other commercial damages or losses),
|
||||
even if such Contributor has been advised of the possibility of such
|
||||
damages.</p>
|
||||
<p><strong><a name="additional">9. Accepting Warranty or Additional Liability</a></strong>.
|
||||
While redistributing the Work or Derivative Works thereof, You may choose
|
||||
to offer, and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this License.
|
||||
However, in accepting such obligations, You may act only on Your own behalf
|
||||
and on Your sole responsibility, not on behalf of any other Contributor,
|
||||
and only if You agree to indemnify, defend, and hold each Contributor
|
||||
harmless for any liability incurred by, or claims asserted against, such
|
||||
Contributor by reason of your accepting any such warranty or additional
|
||||
liability.</p>
|
||||
|
||||
<p>END OF TERMS AND CONDITIONS</p>
|
||||
@@ -1,28 +0,0 @@
|
||||
<h3>BSD License (3-clause)</h3>
|
||||
|
||||
<p>Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:</p>
|
||||
|
||||
<p>o Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.</p>
|
||||
|
||||
<p>o Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.</p>
|
||||
|
||||
<p>o Neither the name of Ben Fortuna nor the names of any other contributors
|
||||
may be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.</p>
|
||||
|
||||
<p>THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||||
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.</p>
|
||||
@@ -1,23 +0,0 @@
|
||||
<h3>BSD License</h3>
|
||||
|
||||
<p>Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:</p>
|
||||
|
||||
<p>1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.</p>
|
||||
|
||||
<p>2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.</p>
|
||||
|
||||
<p>THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
||||
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.</p>
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<p style="text-align: center;">Version 3, 29 June 2007</p>
|
||||
|
||||
<p>Copyright © 2007 Free Software Foundation, Inc.
|
||||
<<a href="http://fsf.org/">http://fsf.org/</a>></p><p>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.</p>
|
||||
<<a href="http://fsf.org/">http://fsf.org/</a>></p><p>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.</p>
|
||||
|
||||
<h3><a name="preamble"></a>Preamble</h3>
|
||||
|
||||
@@ -42,16 +42,16 @@ know their rights.</p>
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.</p>
|
||||
|
||||
<p>For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
<p>For the developers\' and authors\' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users\' and
|
||||
authors\' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.</p>
|
||||
|
||||
<p>Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
protecting users\' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
@@ -77,7 +77,7 @@ modification follow.</p>
|
||||
|
||||
<p>“Copyright” also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.</p>
|
||||
|
||||
|
||||
<p>“The Program” refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as “you”. “Licensees” and
|
||||
“recipients” may be individuals or organizations.</p>
|
||||
@@ -135,7 +135,7 @@ produce the work, or an object code interpreter used to run it.</p>
|
||||
<p>The “Corresponding Source” for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
control those activities. However, it does not include the work\'s
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
@@ -177,7 +177,7 @@ your copyrighted material outside their relationship with you.</p>
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.</p>
|
||||
|
||||
<h4><a name="section3"></a>3. Protecting Users' Legal Rights From Anti-Circumvention Law.</h4>
|
||||
<h4><a name="section3"></a>3. Protecting Users\' Legal Rights From Anti-Circumvention Law.</h4>
|
||||
|
||||
<p>No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
@@ -189,13 +189,13 @@ measures.</p>
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
modification of the work as a means of enforcing, against the work\'s
|
||||
users, your or third parties\' legal rights to forbid circumvention of
|
||||
technological measures.</p>
|
||||
|
||||
<h4><a name="section4"></a>4. Conveying Verbatim Copies.</h4>
|
||||
|
||||
<p>You may convey verbatim copies of the Program's source code as you
|
||||
<p>You may convey verbatim copies of the Program\'s source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
@@ -240,7 +240,7 @@ works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
“aggregate” if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
used to limit the access or legal rights of the compilation\'s users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.</p>
|
||||
@@ -462,7 +462,7 @@ organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
licenses to the work the party\'s predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.</p>
|
||||
@@ -479,9 +479,9 @@ sale, or importing the Program or any portion of it.</p>
|
||||
|
||||
<p>A “contributor” is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's “contributor version”.</p>
|
||||
work thus licensed is called the contributor\'s “contributor version”.</p>
|
||||
|
||||
<p>A contributor's “essential patent claims” are all patent claims
|
||||
<p>A contributor\'s “essential patent claims” are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
@@ -492,7 +492,7 @@ patent sublicenses in a manner consistent with the requirements of
|
||||
this License.</p>
|
||||
|
||||
<p>Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
patent license under the contributor\'s essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.</p>
|
||||
|
||||
@@ -513,10 +513,10 @@ patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. “Knowingly relying” means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
covered work in a country, or your recipient\'s use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.</p>
|
||||
|
||||
|
||||
<p>If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
@@ -544,7 +544,7 @@ or that patent license was granted, prior to 28 March 2007.</p>
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.</p>
|
||||
|
||||
<h4><a name="section12"></a>12. No Surrender of Others' Freedom.</h4>
|
||||
<h4><a name="section12"></a>12. No Surrender of Others\' Freedom.</h4>
|
||||
|
||||
<p>If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
@@ -584,7 +584,7 @@ GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.</p>
|
||||
|
||||
<p>If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
versions of the GNU General Public License can be used, that proxy\'s
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.</p>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<h3>The MIT License (MIT)</h3>
|
||||
|
||||
<p>Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:</p>
|
||||
|
||||
<p>The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.</p>
|
||||
|
||||
<p>THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.</p>
|
||||
BIN
app/src/main/ic_launcher-web.png
Normal file
BIN
app/src/main/ic_launcher-web.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -1,464 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 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
|
||||
*/
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.accounts.AccountManager;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.annotation.TargetApi;
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.ContentProviderClient;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.PeriodicSync;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.drawable.BitmapDrawable;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.provider.CalendarContract;
|
||||
import android.provider.ContactsContract;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v7.app.NotificationCompat;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import at.bitfire.davdroid.model.ServiceDB;
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections;
|
||||
import at.bitfire.davdroid.model.ServiceDB.HomeSets;
|
||||
import at.bitfire.davdroid.model.ServiceDB.Services;
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook;
|
||||
import at.bitfire.davdroid.resource.LocalCalendar;
|
||||
import at.bitfire.davdroid.resource.LocalTaskList;
|
||||
import at.bitfire.ical4android.CalendarStorageException;
|
||||
import at.bitfire.ical4android.TaskProvider;
|
||||
import at.bitfire.vcard4android.ContactsStorageException;
|
||||
import at.bitfire.vcard4android.GroupMethod;
|
||||
import lombok.Cleanup;
|
||||
import okhttp3.HttpUrl;
|
||||
|
||||
public class AccountSettings {
|
||||
private final static int CURRENT_VERSION = 4;
|
||||
private final static String
|
||||
KEY_SETTINGS_VERSION = "version",
|
||||
|
||||
KEY_USERNAME = "user_name",
|
||||
KEY_AUTH_PREEMPTIVE = "auth_preemptive",
|
||||
|
||||
KEY_WIFI_ONLY = "wifi_only", // sync on WiFi only (default: false)
|
||||
KEY_WIFI_ONLY_SSID = "wifi_only_ssid"; // restrict sync to specific WiFi SSID
|
||||
|
||||
/** Whether to use RFC 6868 for VCards
|
||||
* value = null (not existing) use RFC6868-style encoding (default value)
|
||||
* "0" don't use RFC 6868-style encoding
|
||||
*/
|
||||
private final static String KEY_VCARD_RFC6868 = "vcard_rfc6868";
|
||||
|
||||
/** Time range limitation to the past [in days]
|
||||
value = null default value (DEFAULT_TIME_RANGE_PAST_DAYS)
|
||||
< 0 (-1) no limit
|
||||
>= 0 entries more than n days in the past won't be synchronized
|
||||
*/
|
||||
private final static String KEY_TIME_RANGE_PAST_DAYS = "time_range_past_days";
|
||||
private final static int DEFAULT_TIME_RANGE_PAST_DAYS = 90;
|
||||
|
||||
/* Whether DAVdroid sets the local calendar color to the value from service DB at every sync
|
||||
value = null (not existing) true (default)
|
||||
"0" false */
|
||||
private final static String KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors";
|
||||
|
||||
/** Contact group method:
|
||||
value = null (not existing) groups as separate VCards (default)
|
||||
"CATEGORIES" groups are per-contact CATEGORIES
|
||||
*/
|
||||
private final static String KEY_CONTACT_GROUP_METHOD = "contact_group_method";
|
||||
|
||||
public final static long SYNC_INTERVAL_MANUALLY = -1;
|
||||
|
||||
final Context context;
|
||||
final AccountManager accountManager;
|
||||
final Account account;
|
||||
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
public AccountSettings(@NonNull Context context, @NonNull Account account) throws InvalidAccountException {
|
||||
this.context = context;
|
||||
this.account = account;
|
||||
|
||||
accountManager = AccountManager.get(context);
|
||||
|
||||
synchronized(AccountSettings.class) {
|
||||
String versionStr = accountManager.getUserData(account, KEY_SETTINGS_VERSION);
|
||||
if (versionStr == null)
|
||||
throw new InvalidAccountException(account);
|
||||
|
||||
int version = 0;
|
||||
try {
|
||||
version = Integer.parseInt(versionStr);
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
App.log.info("Account " + account.name + " has version " + version + ", current version: " + CURRENT_VERSION);
|
||||
|
||||
if (version < CURRENT_VERSION) {
|
||||
Bitmap bitmapLogo = null;
|
||||
Drawable drawableLogo = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP ?
|
||||
context.getDrawable(R.drawable.ic_launcher) :
|
||||
context.getResources().getDrawable(R.drawable.ic_launcher);
|
||||
if (drawableLogo instanceof BitmapDrawable)
|
||||
bitmapLogo = ((BitmapDrawable)drawableLogo).getBitmap();
|
||||
|
||||
Notification notify = new NotificationCompat.Builder(context)
|
||||
.setSmallIcon(R.drawable.ic_new_releases_light)
|
||||
.setLargeIcon(bitmapLogo)
|
||||
.setContentTitle(context.getString(R.string.settings_version_update))
|
||||
.setContentText(context.getString(R.string.settings_version_update_settings_updated))
|
||||
.setSubText(context.getString(R.string.settings_version_update_install_hint))
|
||||
.setStyle(new NotificationCompat.BigTextStyle()
|
||||
.bigText(context.getString(R.string.settings_version_update_settings_updated)))
|
||||
.setCategory(NotificationCompat.CATEGORY_SYSTEM)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0,
|
||||
new Intent(Intent.ACTION_VIEW, Constants.webUri.buildUpon().appendEncodedPath("faq/entry/davdroid-not-working-after-update/").build()),
|
||||
PendingIntent.FLAG_CANCEL_CURRENT))
|
||||
.setLocalOnly(true)
|
||||
.build();
|
||||
NotificationManager nm = (NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
nm.notify(Constants.NOTIFICATION_ACCOUNT_SETTINGS_UPDATED, notify);
|
||||
|
||||
update(version);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static Bundle initialUserData(String userName, boolean preemptive) {
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putString(KEY_SETTINGS_VERSION, String.valueOf(CURRENT_VERSION));
|
||||
bundle.putString(KEY_USERNAME, userName);
|
||||
bundle.putString(KEY_AUTH_PREEMPTIVE, Boolean.toString(preemptive));
|
||||
return bundle;
|
||||
}
|
||||
|
||||
|
||||
// authentication settings
|
||||
|
||||
public String username() { return accountManager.getUserData(account, KEY_USERNAME); }
|
||||
public void username(@NonNull String userName) { accountManager.setUserData(account, KEY_USERNAME, userName); }
|
||||
|
||||
public String password() { return accountManager.getPassword(account); }
|
||||
public void password(@NonNull String password) { accountManager.setPassword(account, password); }
|
||||
|
||||
public boolean preemptiveAuth() { return Boolean.parseBoolean(accountManager.getUserData(account, KEY_AUTH_PREEMPTIVE)); }
|
||||
public void preemptiveAuth(boolean preemptive) { accountManager.setUserData(account, KEY_AUTH_PREEMPTIVE, Boolean.toString(preemptive)); }
|
||||
|
||||
|
||||
// sync. settings
|
||||
|
||||
public Long getSyncInterval(@NonNull String authority) {
|
||||
if (ContentResolver.getIsSyncable(account, authority) <= 0)
|
||||
return null;
|
||||
|
||||
if (ContentResolver.getSyncAutomatically(account, authority)) {
|
||||
List<PeriodicSync> syncs = ContentResolver.getPeriodicSyncs(account, authority);
|
||||
if (syncs.isEmpty())
|
||||
return SYNC_INTERVAL_MANUALLY;
|
||||
else
|
||||
return syncs.get(0).period;
|
||||
} else
|
||||
return SYNC_INTERVAL_MANUALLY;
|
||||
}
|
||||
|
||||
public void setSyncInterval(@NonNull String authority, long seconds) {
|
||||
if (seconds == SYNC_INTERVAL_MANUALLY) {
|
||||
ContentResolver.setSyncAutomatically(account, authority, false);
|
||||
} else {
|
||||
ContentResolver.setSyncAutomatically(account, authority, true);
|
||||
ContentResolver.addPeriodicSync(account, authority, new Bundle(), seconds);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean getSyncWifiOnly() {
|
||||
return accountManager.getUserData(account, KEY_WIFI_ONLY) != null;
|
||||
}
|
||||
|
||||
public void setSyncWiFiOnly(boolean wiFiOnly) {
|
||||
accountManager.setUserData(account, KEY_WIFI_ONLY, wiFiOnly ? "1" : null);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getSyncWifiOnlySSID() {
|
||||
return accountManager.getUserData(account, KEY_WIFI_ONLY_SSID);
|
||||
}
|
||||
|
||||
public void setSyncWifiOnlySSID(String ssid) {
|
||||
accountManager.setUserData(account, KEY_WIFI_ONLY_SSID, ssid);
|
||||
}
|
||||
|
||||
|
||||
// CardDAV settings
|
||||
|
||||
public boolean getVCardRFC6868() {
|
||||
return accountManager.getUserData(account, KEY_VCARD_RFC6868) == null;
|
||||
}
|
||||
|
||||
public void setVCardRFC6868(boolean use) {
|
||||
accountManager.setUserData(account, KEY_VCARD_RFC6868, use ? null : "0");
|
||||
}
|
||||
|
||||
|
||||
// CalDAV settings
|
||||
|
||||
@Nullable
|
||||
public Integer getTimeRangePastDays() {
|
||||
String strDays = accountManager.getUserData(account, KEY_TIME_RANGE_PAST_DAYS);
|
||||
if (strDays != null) {
|
||||
int days = Integer.valueOf(strDays);
|
||||
return days < 0 ? null : days;
|
||||
} else
|
||||
return DEFAULT_TIME_RANGE_PAST_DAYS;
|
||||
}
|
||||
|
||||
public void setTimeRangePastDays(@Nullable Integer days) {
|
||||
accountManager.setUserData(account, KEY_TIME_RANGE_PAST_DAYS, String.valueOf(days == null ? -1 : days));
|
||||
}
|
||||
|
||||
public boolean getManageCalendarColors() {
|
||||
return accountManager.getUserData(account, KEY_MANAGE_CALENDAR_COLORS) == null;
|
||||
}
|
||||
|
||||
public void setManageCalendarColors(boolean manage) {
|
||||
accountManager.setUserData(account, KEY_MANAGE_CALENDAR_COLORS, manage ? null : "0");
|
||||
}
|
||||
|
||||
|
||||
// CardDAV settings
|
||||
|
||||
@NonNull
|
||||
public GroupMethod getGroupMethod() {
|
||||
final String name = accountManager.getUserData(account, KEY_CONTACT_GROUP_METHOD);
|
||||
return name != null ?
|
||||
GroupMethod.valueOf(name) :
|
||||
GroupMethod.GROUP_VCARDS;
|
||||
}
|
||||
|
||||
public void setGroupMethod(@NonNull GroupMethod method) {
|
||||
final String name = method == GroupMethod.GROUP_VCARDS ? null : method.name();
|
||||
accountManager.setUserData(account, KEY_CONTACT_GROUP_METHOD, name);
|
||||
}
|
||||
|
||||
|
||||
// update from previous account settings
|
||||
|
||||
private void update(int fromVersion) {
|
||||
for (int toVersion = fromVersion + 1; toVersion <= CURRENT_VERSION; toVersion++) {
|
||||
App.log.info("Updating account " + account.name + " from version " + fromVersion + " to " + toVersion);
|
||||
try {
|
||||
Method updateProc = getClass().getDeclaredMethod("update_" + fromVersion + "_" + toVersion);
|
||||
updateProc.invoke(this);
|
||||
accountManager.setUserData(account, KEY_SETTINGS_VERSION, String.valueOf(toVersion));
|
||||
} catch (Exception e) {
|
||||
App.log.log(Level.SEVERE, "Couldn't update account settings", e);
|
||||
}
|
||||
fromVersion = toVersion;
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings({ "Recycle", "unused" })
|
||||
private void update_1_2() throws ContactsStorageException {
|
||||
/* - KEY_ADDRESSBOOK_URL ("addressbook_url"),
|
||||
- KEY_ADDRESSBOOK_CTAG ("addressbook_ctag"),
|
||||
- KEY_ADDRESSBOOK_VCARD_VERSION ("addressbook_vcard_version") are not used anymore (now stored in ContactsContract.SyncState)
|
||||
- KEY_LAST_ANDROID_VERSION ("last_android_version") has been added
|
||||
*/
|
||||
|
||||
// move previous address book info to ContactsContract.SyncState
|
||||
@Cleanup("release") ContentProviderClient provider = context.getContentResolver().acquireContentProviderClient(ContactsContract.AUTHORITY);
|
||||
if (provider == null)
|
||||
throw new ContactsStorageException("Couldn't access Contacts provider");
|
||||
|
||||
LocalAddressBook addr = new LocalAddressBook(account, provider);
|
||||
|
||||
// until now, ContactsContract.Settings.UNGROUPED_VISIBLE was not set explicitly
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1);
|
||||
addr.updateSettings(values);
|
||||
|
||||
String url = accountManager.getUserData(account, "addressbook_url");
|
||||
if (!TextUtils.isEmpty(url))
|
||||
addr.setURL(url);
|
||||
accountManager.setUserData(account, "addressbook_url", null);
|
||||
|
||||
String cTag = accountManager.getUserData(account, "addressbook_ctag");
|
||||
if (!TextUtils.isEmpty(cTag))
|
||||
addr.setCTag(cTag);
|
||||
accountManager.setUserData(account, "addressbook_ctag", null);
|
||||
}
|
||||
|
||||
@SuppressWarnings({ "Recycle", "unused" })
|
||||
private void update_2_3() {
|
||||
// Don't show a warning for Android updates anymore
|
||||
accountManager.setUserData(account, "last_android_version", null);
|
||||
|
||||
Long serviceCardDAV = null, serviceCalDAV = null;
|
||||
|
||||
ServiceDB.OpenHelper dbHelper = new ServiceDB.OpenHelper(context);
|
||||
try {
|
||||
SQLiteDatabase db = dbHelper.getWritableDatabase();
|
||||
// we have to create the WebDAV Service database only from the old address book, calendar and task list URLs
|
||||
|
||||
// CardDAV: migrate address books
|
||||
ContentProviderClient client = context.getContentResolver().acquireContentProviderClient(ContactsContract.AUTHORITY);
|
||||
if (client != null)
|
||||
try {
|
||||
LocalAddressBook addrBook = new LocalAddressBook(account, client);
|
||||
String url = addrBook.getURL();
|
||||
if (url != null) {
|
||||
App.log.fine("Migrating address book " + url);
|
||||
|
||||
// insert CardDAV service
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(Services.ACCOUNT_NAME, account.name);
|
||||
values.put(Services.SERVICE, Services.SERVICE_CARDDAV);
|
||||
serviceCardDAV = db.insert(Services._TABLE, null, values);
|
||||
|
||||
// insert address book
|
||||
values.clear();
|
||||
values.put(Collections.SERVICE_ID, serviceCardDAV);
|
||||
values.put(Collections.URL, url);
|
||||
values.put(Collections.SYNC, 1);
|
||||
db.insert(Collections._TABLE, null, values);
|
||||
|
||||
// insert home set
|
||||
HttpUrl homeSet = HttpUrl.parse(url).resolve("../");
|
||||
values.clear();
|
||||
values.put(HomeSets.SERVICE_ID, serviceCardDAV);
|
||||
values.put(HomeSets.URL, homeSet.toString());
|
||||
db.insert(HomeSets._TABLE, null, values);
|
||||
}
|
||||
|
||||
} catch (ContactsStorageException e) {
|
||||
App.log.log(Level.SEVERE, "Couldn't migrate address book", e);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
// CalDAV: migrate calendars + task lists
|
||||
Set<String> collections = new HashSet<>();
|
||||
Set<HttpUrl> homeSets = new HashSet<>();
|
||||
|
||||
client = context.getContentResolver().acquireContentProviderClient(CalendarContract.AUTHORITY);
|
||||
if (client != null)
|
||||
try {
|
||||
LocalCalendar calendars[] = (LocalCalendar[])LocalCalendar.find(account, client, LocalCalendar.Factory.INSTANCE, null, null);
|
||||
for (LocalCalendar calendar : calendars) {
|
||||
String url = calendar.getName();
|
||||
App.log.fine("Migrating calendar " + url);
|
||||
collections.add(url);
|
||||
homeSets.add(HttpUrl.parse(url).resolve("../"));
|
||||
}
|
||||
} catch (CalendarStorageException e) {
|
||||
App.log.log(Level.SEVERE, "Couldn't migrate calendars", e);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
TaskProvider provider = LocalTaskList.acquireTaskProvider(context.getContentResolver());
|
||||
if (provider != null)
|
||||
try {
|
||||
LocalTaskList[] taskLists = (LocalTaskList[])LocalTaskList.find(account, provider, LocalTaskList.Factory.INSTANCE, null, null);
|
||||
for (LocalTaskList taskList : taskLists) {
|
||||
String url = taskList.getSyncId();
|
||||
App.log.fine("Migrating task list " + url);
|
||||
collections.add(url);
|
||||
homeSets.add(HttpUrl.parse(url).resolve("../"));
|
||||
}
|
||||
} catch (CalendarStorageException e) {
|
||||
App.log.log(Level.SEVERE, "Couldn't migrate task lists", e);
|
||||
} finally {
|
||||
provider.close();
|
||||
}
|
||||
|
||||
if (!collections.isEmpty()) {
|
||||
// insert CalDAV service
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(Services.ACCOUNT_NAME, account.name);
|
||||
values.put(Services.SERVICE, Services.SERVICE_CALDAV);
|
||||
serviceCalDAV = db.insert(Services._TABLE, null, values);
|
||||
|
||||
// insert collections
|
||||
for (String url : collections) {
|
||||
values.clear();
|
||||
values.put(Collections.SERVICE_ID, serviceCalDAV);
|
||||
values.put(Collections.URL, url);
|
||||
values.put(Collections.SYNC, 1);
|
||||
db.insert(Collections._TABLE, null, values);
|
||||
}
|
||||
|
||||
// insert home sets
|
||||
for (HttpUrl homeSet : homeSets) {
|
||||
values.clear();
|
||||
values.put(HomeSets.SERVICE_ID, serviceCalDAV);
|
||||
values.put(HomeSets.URL, homeSet.toString());
|
||||
db.insert(HomeSets._TABLE, null, values);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
dbHelper.close();
|
||||
}
|
||||
|
||||
// initiate service detection (refresh) to get display names, colors etc.
|
||||
Intent refresh = new Intent(context, DavService.class);
|
||||
refresh.setAction(DavService.ACTION_REFRESH_COLLECTIONS);
|
||||
if (serviceCardDAV != null) {
|
||||
refresh.putExtra(DavService.EXTRA_DAV_SERVICE_ID, serviceCardDAV);
|
||||
context.startService(refresh);
|
||||
}
|
||||
if (serviceCalDAV != null) {
|
||||
refresh.putExtra(DavService.EXTRA_DAV_SERVICE_ID, serviceCalDAV);
|
||||
context.startService(refresh);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings({ "Recycle", "unused" })
|
||||
private void update_3_4() {
|
||||
setGroupMethod(GroupMethod.CATEGORIES);
|
||||
}
|
||||
|
||||
|
||||
public static class AppUpdatedReceiver extends BroadcastReceiver {
|
||||
|
||||
@Override
|
||||
@SuppressLint("UnsafeProtectedBroadcastReceiver")
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
App.log.info("DAVdroid was updated, checking for AccountSettings version");
|
||||
|
||||
// peek into AccountSettings to initiate a possible migration
|
||||
AccountManager accountManager = AccountManager.get(context);
|
||||
for (Account account : accountManager.getAccountsByType(Constants.ACCOUNT_TYPE))
|
||||
try {
|
||||
App.log.info("Checking account " + account.name);
|
||||
new AccountSettings(context, account);
|
||||
} catch (InvalidAccountException e) {
|
||||
App.log.log(Level.SEVERE, "Couldn't check for updated account settings", e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2016 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import android.accounts.OnAccountsUpdateListener;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public class AccountsChangedReceiver extends BroadcastReceiver {
|
||||
|
||||
protected static final List<OnAccountsUpdateListener> listeners = new LinkedList<>();
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
Intent serviceIntent = new Intent(context, DavService.class);
|
||||
serviceIntent.setAction(DavService.ACTION_ACCOUNTS_UPDATED);
|
||||
context.startService(serviceIntent);
|
||||
|
||||
for (OnAccountsUpdateListener listener : listeners)
|
||||
listener.onAccountsUpdated(null);
|
||||
}
|
||||
|
||||
public static void registerListener(OnAccountsUpdateListener listener, boolean callImmediately) {
|
||||
listeners.add(listener);
|
||||
if (callImmediately)
|
||||
listener.onAccountsUpdated(null);
|
||||
}
|
||||
|
||||
public static void unregisterListener(OnAccountsUpdateListener listener) {
|
||||
listeners.remove(listener);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2016 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import android.app.Application;
|
||||
import android.app.NotificationManager;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.drawable.BitmapDrawable;
|
||||
import android.support.v7.app.NotificationCompat;
|
||||
import android.util.Log;
|
||||
|
||||
import org.apache.commons.lang3.time.DateFormatUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.logging.FileHandler;
|
||||
import java.util.logging.Handler;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import javax.net.ssl.HostnameVerifier;
|
||||
|
||||
import at.bitfire.davdroid.log.LogcatHandler;
|
||||
import at.bitfire.davdroid.log.PlainTextFormatter;
|
||||
import at.bitfire.davdroid.model.ServiceDB;
|
||||
import at.bitfire.davdroid.model.Settings;
|
||||
import de.duenndns.ssl.MemorizingTrustManager;
|
||||
import lombok.Cleanup;
|
||||
import lombok.Getter;
|
||||
import okhttp3.internal.tls.OkHostnameVerifier;
|
||||
|
||||
public class App extends Application {
|
||||
public static final String FLAVOR_GOOGLE_PLAY = "gplay";
|
||||
|
||||
public static final String LOG_TO_EXTERNAL_STORAGE = "logToExternalStorage";
|
||||
|
||||
@Getter
|
||||
private static MemorizingTrustManager memorizingTrustManager;
|
||||
|
||||
@Getter
|
||||
private static SSLSocketFactoryCompat sslSocketFactoryCompat;
|
||||
|
||||
@Getter
|
||||
private static HostnameVerifier hostnameVerifier;
|
||||
|
||||
public final static Logger log = Logger.getLogger("davdroid");
|
||||
static {
|
||||
at.bitfire.dav4android.Constants.log = Logger.getLogger("davdroid.dav4android");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
|
||||
// initialize MemorizingTrustManager
|
||||
memorizingTrustManager = new MemorizingTrustManager(this);
|
||||
sslSocketFactoryCompat = new SSLSocketFactoryCompat(memorizingTrustManager);
|
||||
hostnameVerifier = memorizingTrustManager.wrapHostnameVerifier(OkHostnameVerifier.INSTANCE);
|
||||
|
||||
// initializer logger
|
||||
reinitLogger();
|
||||
}
|
||||
|
||||
public void reinitLogger() {
|
||||
// don't use Android default logging, we have our own handlers
|
||||
log.setUseParentHandlers(false);
|
||||
|
||||
@Cleanup ServiceDB.OpenHelper dbHelper = new ServiceDB.OpenHelper(this);
|
||||
Settings settings = new Settings(dbHelper.getReadableDatabase());
|
||||
|
||||
boolean logToFile = settings.getBoolean(LOG_TO_EXTERNAL_STORAGE, false),
|
||||
logVerbose = logToFile || Log.isLoggable(log.getName(), Log.DEBUG);
|
||||
|
||||
// set logging level according to preferences
|
||||
log.setLevel(logVerbose ? Level.ALL : Level.INFO);
|
||||
|
||||
// remove all handlers
|
||||
for (Handler handler : log.getHandlers())
|
||||
log.removeHandler(handler);
|
||||
|
||||
// add logcat handler
|
||||
log.addHandler(LogcatHandler.INSTANCE);
|
||||
|
||||
NotificationManager nm = (NotificationManager)getSystemService(NOTIFICATION_SERVICE);
|
||||
// log to external file according to preferences
|
||||
if (logToFile) {
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(this);
|
||||
builder .setSmallIcon(R.drawable.ic_sd_storage_light)
|
||||
.setLargeIcon(((BitmapDrawable)getResources().getDrawable(R.drawable.ic_launcher)).getBitmap())
|
||||
.setContentTitle(getString(R.string.logging_davdroid_file_logging))
|
||||
.setLocalOnly(true);
|
||||
|
||||
File dir = getExternalFilesDir(null);
|
||||
if (dir != null)
|
||||
try {
|
||||
String fileName = new File(dir, "davdroid-" + android.os.Process.myPid() + "-" +
|
||||
DateFormatUtils.format(System.currentTimeMillis(), "yyyyMMdd-HHmmss") + ".txt").toString();
|
||||
log.info("Logging to " + fileName);
|
||||
|
||||
FileHandler fileHandler = new FileHandler(fileName);
|
||||
fileHandler.setFormatter(PlainTextFormatter.DEFAULT);
|
||||
log.addHandler(fileHandler);
|
||||
builder .setContentText(dir.getPath())
|
||||
.setSubText(getString(R.string.logging_to_external_storage_warning))
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setStyle(new NotificationCompat.BigTextStyle()
|
||||
.bigText(getString(R.string.logging_to_external_storage, dir.getPath())))
|
||||
.setOngoing(true);
|
||||
|
||||
} catch (IOException e) {
|
||||
log.log(Level.SEVERE, "Couldn't create external log file", e);
|
||||
|
||||
builder .setContentText(getString(R.string.logging_couldnt_create_file, e.getLocalizedMessage()))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR);
|
||||
}
|
||||
else
|
||||
builder.setContentText(getString(R.string.logging_no_external_storage));
|
||||
|
||||
nm.notify(Constants.NOTIFICATION_EXTERNAL_FILE_LOGGING, builder.build());
|
||||
} else
|
||||
nm.cancel(Constants.NOTIFICATION_EXTERNAL_FILE_LOGGING);
|
||||
}
|
||||
|
||||
|
||||
public static class ReinitLoggingReceiver extends BroadcastReceiver {
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
log.info("Received broadcast: re-initializing logger");
|
||||
|
||||
App app = (App)context.getApplicationContext();
|
||||
app.reinitLogger();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
83
app/src/main/java/at/bitfire/davdroid/App.kt
Normal file
83
app/src/main/java/at/bitfire/davdroid/App.kt
Normal file
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.StrictMode
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.multidex.MultiDexApplication
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
@Suppress("unused")
|
||||
class App: MultiDexApplication() {
|
||||
|
||||
companion object {
|
||||
|
||||
fun getLauncherBitmap(context: Context): Bitmap? {
|
||||
val drawableLogo = AppCompatResources.getDrawable(context, R.mipmap.ic_launcher)
|
||||
return if (drawableLogo is BitmapDrawable)
|
||||
drawableLogo.bitmap
|
||||
else
|
||||
null
|
||||
}
|
||||
|
||||
fun homepageUrl(context: Context) =
|
||||
Uri.parse(context.getString(R.string.homepage_url)).buildUpon()
|
||||
.appendQueryParameter("pk_campaign", BuildConfig.APPLICATION_ID)
|
||||
.appendQueryParameter("pk_kwd", context::class.java.simpleName)
|
||||
.appendQueryParameter("app-version", BuildConfig.VERSION_NAME)
|
||||
.build()!!
|
||||
|
||||
}
|
||||
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Logger.initialize(this)
|
||||
|
||||
if (BuildConfig.DEBUG)
|
||||
StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder()
|
||||
.detectActivityLeaks()
|
||||
.detectFileUriExposure()
|
||||
.detectLeakedClosableObjects()
|
||||
.detectLeakedRegistrationObjects()
|
||||
.detectLeakedSqlLiteObjects()
|
||||
.penaltyLog()
|
||||
.build())
|
||||
|
||||
if (Build.VERSION.SDK_INT <= 21)
|
||||
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
|
||||
|
||||
NotificationUtils.createChannels(this)
|
||||
|
||||
// don't block UI for some background checks
|
||||
thread {
|
||||
// watch installed/removed apps
|
||||
val tasksFilter = IntentFilter()
|
||||
tasksFilter.addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
tasksFilter.addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED)
|
||||
tasksFilter.addDataScheme("package")
|
||||
registerReceiver(PackageChangedReceiver(), tasksFilter)
|
||||
|
||||
// check whether a tasks app is currently installed
|
||||
PackageChangedReceiver.updateTaskSync(this)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 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
|
||||
*/
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import java.lang.reflect.Array;
|
||||
|
||||
public class ArrayUtils {
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public static <T> T[][] partition(T[] bigArray, int max) {
|
||||
int nItems = bigArray.length;
|
||||
int nPartArrays = (nItems + max-1)/max;
|
||||
|
||||
T[][] partArrays = (T[][])Array.newInstance(bigArray.getClass().getComponentType(), nPartArrays, 0);
|
||||
|
||||
// nItems is now the number of remaining items
|
||||
for (int i = 0; nItems > 0; i++) {
|
||||
int n = (nItems < max) ? nItems : max;
|
||||
partArrays[i] = (T[])Array.newInstance(bigArray.getClass().getComponentType(), n);
|
||||
System.arraycopy(bigArray, i*max, partArrays[i], 0, n);
|
||||
|
||||
nItems -= n;
|
||||
}
|
||||
|
||||
return partArrays;
|
||||
}
|
||||
|
||||
}
|
||||
11
app/src/main/java/at/bitfire/davdroid/CompatUtils.kt
Normal file
11
app/src/main/java/at/bitfire/davdroid/CompatUtils.kt
Normal file
@@ -0,0 +1,11 @@
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.content.ContentProviderClient
|
||||
import android.os.Build
|
||||
|
||||
fun ContentProviderClient.closeCompat() {
|
||||
if (Build.VERSION.SDK_INT >= 24)
|
||||
close()
|
||||
else
|
||||
release()
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 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
|
||||
*/
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
public class Constants {
|
||||
|
||||
public static final String
|
||||
ACCOUNT_TYPE = "bitfire.at.davdroid";
|
||||
|
||||
// notification IDs
|
||||
public final static int
|
||||
NOTIFICATION_ACCOUNT_SETTINGS_UPDATED = 0,
|
||||
NOTIFICATION_EXTERNAL_FILE_LOGGING = 1,
|
||||
NOTIFICATION_REFRESH_COLLECTIONS = 2,
|
||||
NOTIFICATION_CONTACTS_SYNC = 10,
|
||||
NOTIFICATION_CALENDAR_SYNC = 11,
|
||||
NOTIFICATION_TASK_SYNC = 12,
|
||||
NOTIFICATION_PERMISSIONS = 20;
|
||||
|
||||
public static final Uri webUri = Uri.parse("https://davdroid.bitfire.at/?pk_campaign=davdroid-app");
|
||||
|
||||
}
|
||||
30
app/src/main/java/at/bitfire/davdroid/Constants.kt
Normal file
30
app/src/main/java/at/bitfire/davdroid/Constants.kt
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
package at.bitfire.davdroid
|
||||
|
||||
object Constants {
|
||||
|
||||
const val DAVDROID_GREEN_RGBA = 0xFF8bc34a.toInt()
|
||||
|
||||
const val DEFAULT_SYNC_INTERVAL = 4 * 3600L // 4 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"
|
||||
|
||||
}
|
||||
@@ -1,427 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2016 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.accounts.AccountManager;
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.database.DatabaseUtils;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
import android.graphics.drawable.BitmapDrawable;
|
||||
import android.os.Binder;
|
||||
import android.os.IBinder;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v7.app.NotificationCompat;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import org.apache.commons.collections4.iterators.IteratorChain;
|
||||
import org.apache.commons.collections4.iterators.SingletonIterator;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import at.bitfire.dav4android.DavResource;
|
||||
import at.bitfire.dav4android.UrlUtils;
|
||||
import at.bitfire.dav4android.exception.DavException;
|
||||
import at.bitfire.dav4android.exception.HttpException;
|
||||
import at.bitfire.dav4android.property.AddressbookHomeSet;
|
||||
import at.bitfire.dav4android.property.CalendarHomeSet;
|
||||
import at.bitfire.dav4android.property.CalendarProxyReadFor;
|
||||
import at.bitfire.dav4android.property.CalendarProxyWriteFor;
|
||||
import at.bitfire.dav4android.property.GroupMembership;
|
||||
import at.bitfire.davdroid.model.CollectionInfo;
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections;
|
||||
import at.bitfire.davdroid.model.ServiceDB.HomeSets;
|
||||
import at.bitfire.davdroid.model.ServiceDB.OpenHelper;
|
||||
import at.bitfire.davdroid.model.ServiceDB.Services;
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity;
|
||||
import lombok.Cleanup;
|
||||
import okhttp3.HttpUrl;
|
||||
import okhttp3.OkHttpClient;
|
||||
|
||||
public class DavService extends Service {
|
||||
|
||||
public static final String
|
||||
ACTION_ACCOUNTS_UPDATED = "accountsUpdated",
|
||||
ACTION_REFRESH_COLLECTIONS = "refreshCollections",
|
||||
EXTRA_DAV_SERVICE_ID = "davServiceID";
|
||||
|
||||
private final IBinder binder = new InfoBinder();
|
||||
|
||||
private final Set<Long> runningRefresh = new HashSet<>();
|
||||
private final List<WeakReference<RefreshingStatusListener>> refreshingStatusListeners = new LinkedList<>();
|
||||
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
if (intent != null) {
|
||||
String action = intent.getAction();
|
||||
long id = intent.getLongExtra(EXTRA_DAV_SERVICE_ID, -1);
|
||||
|
||||
switch (action) {
|
||||
case ACTION_ACCOUNTS_UPDATED:
|
||||
cleanupAccounts();
|
||||
break;
|
||||
case ACTION_REFRESH_COLLECTIONS:
|
||||
if (runningRefresh.add(id)) {
|
||||
new Thread(new RefreshCollections(id)).start();
|
||||
for (WeakReference<RefreshingStatusListener> ref : refreshingStatusListeners) {
|
||||
RefreshingStatusListener listener = ref.get();
|
||||
if (listener != null)
|
||||
listener.onDavRefreshStatusChanged(id, true);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
|
||||
/* BOUND SERVICE PART
|
||||
for communicating with the activities
|
||||
*/
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return binder;
|
||||
}
|
||||
|
||||
public interface RefreshingStatusListener {
|
||||
void onDavRefreshStatusChanged(long id, boolean refreshing);
|
||||
}
|
||||
|
||||
public class InfoBinder extends Binder {
|
||||
public boolean isRefreshing(long id) {
|
||||
return runningRefresh.contains(id);
|
||||
}
|
||||
|
||||
public void addRefreshingStatusListener(@NonNull RefreshingStatusListener listener, boolean callImmediate) {
|
||||
refreshingStatusListeners.add(new WeakReference<>(listener));
|
||||
if (callImmediate)
|
||||
for (long id : runningRefresh)
|
||||
listener.onDavRefreshStatusChanged(id, true);
|
||||
}
|
||||
|
||||
public void removeRefreshingStatusListener(@NonNull RefreshingStatusListener listener) {
|
||||
for (Iterator<WeakReference<RefreshingStatusListener>> iterator = refreshingStatusListeners.iterator(); iterator.hasNext(); ) {
|
||||
RefreshingStatusListener item = iterator.next().get();
|
||||
if (listener.equals(item))
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ACTION RUNNABLES
|
||||
which actually do the work
|
||||
*/
|
||||
|
||||
void cleanupAccounts() {
|
||||
App.log.info("Cleaning up orphaned accounts");
|
||||
|
||||
final OpenHelper dbHelper = new OpenHelper(this);
|
||||
try {
|
||||
SQLiteDatabase db = dbHelper.getWritableDatabase();
|
||||
|
||||
List<String> sqlAccountNames = new LinkedList<>();
|
||||
AccountManager am = AccountManager.get(this);
|
||||
for (Account account : am.getAccountsByType(Constants.ACCOUNT_TYPE))
|
||||
sqlAccountNames.add(DatabaseUtils.sqlEscapeString(account.name));
|
||||
|
||||
if (sqlAccountNames.isEmpty())
|
||||
db.delete(Services._TABLE, null, null);
|
||||
else
|
||||
db.delete(Services._TABLE, Services.ACCOUNT_NAME + " NOT IN (" + TextUtils.join(",", sqlAccountNames) + ")", null);
|
||||
} finally {
|
||||
dbHelper.close();
|
||||
}
|
||||
}
|
||||
|
||||
private class RefreshCollections implements Runnable {
|
||||
final long service;
|
||||
final OpenHelper dbHelper;
|
||||
SQLiteDatabase db;
|
||||
|
||||
RefreshCollections(long davServiceId) {
|
||||
this.service = davServiceId;
|
||||
dbHelper = new OpenHelper(DavService.this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
Account account = null;
|
||||
|
||||
try {
|
||||
db = dbHelper.getWritableDatabase();
|
||||
|
||||
String serviceType = serviceType();
|
||||
App.log.info("Refreshing " + serviceType + " collections of service #" + service);
|
||||
|
||||
// get account
|
||||
account = account();
|
||||
|
||||
// create authenticating OkHttpClient (credentials taken from account settings)
|
||||
OkHttpClient httpClient = HttpClient.create(DavService.this, account);
|
||||
|
||||
// refresh home sets: principal
|
||||
Set<HttpUrl> homeSets = readHomeSets();
|
||||
HttpUrl principal = readPrincipal();
|
||||
if (principal != null) {
|
||||
App.log.fine("Querying principal for home sets");
|
||||
DavResource dav = new DavResource(httpClient, principal);
|
||||
queryHomeSets(serviceType, dav, homeSets);
|
||||
|
||||
// refresh home sets: calendar-proxy-read/write-for
|
||||
CalendarProxyReadFor proxyRead = (CalendarProxyReadFor)dav.properties.get(CalendarProxyReadFor.NAME);
|
||||
if (proxyRead != null)
|
||||
for (String href : proxyRead.principals) {
|
||||
App.log.fine("Principal is a read-only proxy for " + href + ", checking for home sets");
|
||||
queryHomeSets(serviceType, new DavResource(httpClient, dav.location.resolve(href)), homeSets);
|
||||
}
|
||||
CalendarProxyWriteFor proxyWrite = (CalendarProxyWriteFor)dav.properties.get(CalendarProxyWriteFor.NAME);
|
||||
if (proxyWrite != null)
|
||||
for (String href : proxyWrite.principals) {
|
||||
App.log.fine("Principal is a read-write proxy for " + href + ", checking for home sets");
|
||||
queryHomeSets(serviceType, new DavResource(httpClient, dav.location.resolve(href)), homeSets);
|
||||
}
|
||||
|
||||
// refresh home sets: direct group memberships
|
||||
GroupMembership groupMembership = (GroupMembership)dav.properties.get(GroupMembership.NAME);
|
||||
if (groupMembership != null)
|
||||
for (String href : groupMembership.hrefs) {
|
||||
App.log.fine("Principal is member of group " + href + ", checking for home sets");
|
||||
DavResource group = new DavResource(httpClient, dav.location.resolve(href));
|
||||
try {
|
||||
queryHomeSets(serviceType, group, homeSets);
|
||||
} catch(HttpException|DavException e) {
|
||||
App.log.log(Level.WARNING, "Couldn't query member group ", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// now refresh collections (taken from home sets)
|
||||
Map<HttpUrl, CollectionInfo> collections = readCollections();
|
||||
|
||||
// (remember selections before)
|
||||
Set<HttpUrl> selectedCollections = new HashSet<>();
|
||||
for (CollectionInfo info : collections.values())
|
||||
if (info.selected)
|
||||
selectedCollections.add(HttpUrl.parse(info.url));
|
||||
|
||||
for (Iterator<HttpUrl> itHomeSets = homeSets.iterator(); itHomeSets.hasNext(); ) {
|
||||
HttpUrl homeSet = itHomeSets.next();
|
||||
App.log.fine("Listing home set " + homeSet);
|
||||
|
||||
DavResource dav = new DavResource(httpClient, homeSet);
|
||||
try {
|
||||
dav.propfind(1, CollectionInfo.DAV_PROPERTIES);
|
||||
IteratorChain<DavResource> itCollections = new IteratorChain<>(dav.members.iterator(), new SingletonIterator(dav));
|
||||
while (itCollections.hasNext()) {
|
||||
DavResource member = itCollections.next();
|
||||
CollectionInfo info = CollectionInfo.fromDavResource(member);
|
||||
info.confirmed = true;
|
||||
App.log.log(Level.FINE, "Found collection", info);
|
||||
|
||||
if ((serviceType.equals(Services.SERVICE_CARDDAV) && info.type == CollectionInfo.Type.ADDRESS_BOOK) ||
|
||||
(serviceType.equals(Services.SERVICE_CALDAV) && info.type == CollectionInfo.Type.CALENDAR))
|
||||
collections.put(member.location, info);
|
||||
}
|
||||
} catch(HttpException e) {
|
||||
if (e.status == 403 || e.status == 404 || e.status == 410)
|
||||
// delete home set only if it was not accessible (40x)
|
||||
itHomeSets.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// check/refresh unconfirmed collections
|
||||
for (Iterator<Map.Entry<HttpUrl, CollectionInfo>> iterator = collections.entrySet().iterator(); iterator.hasNext(); ) {
|
||||
Map.Entry<HttpUrl, CollectionInfo> entry = iterator.next();
|
||||
HttpUrl url = entry.getKey();
|
||||
CollectionInfo info = entry.getValue();
|
||||
|
||||
if (!info.confirmed)
|
||||
try {
|
||||
DavResource dav = new DavResource(httpClient, url);
|
||||
dav.propfind(0, CollectionInfo.DAV_PROPERTIES);
|
||||
info = CollectionInfo.fromDavResource(dav);
|
||||
info.confirmed = true;
|
||||
|
||||
// remove unusable collections
|
||||
if ((serviceType.equals(Services.SERVICE_CARDDAV) && info.type != CollectionInfo.Type.ADDRESS_BOOK) ||
|
||||
(serviceType.equals(Services.SERVICE_CALDAV) && info.type != CollectionInfo.Type.CALENDAR))
|
||||
iterator.remove();
|
||||
} catch(HttpException e) {
|
||||
if (e.status == 403 || e.status == 404 || e.status == 410)
|
||||
// delete collection only if it was not accessible (40x)
|
||||
iterator.remove();
|
||||
else
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// restore selections
|
||||
for (HttpUrl url : selectedCollections) {
|
||||
CollectionInfo info = collections.get(url);
|
||||
if (info != null)
|
||||
info.selected = true;
|
||||
}
|
||||
|
||||
try {
|
||||
db.beginTransactionNonExclusive();
|
||||
saveHomeSets(homeSets);
|
||||
saveCollections(collections.values());
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
|
||||
} catch(InvalidAccountException e) {
|
||||
App.log.log(Level.SEVERE, "Invalid account", e);
|
||||
} catch(IOException|HttpException|DavException e) {
|
||||
App.log.log(Level.SEVERE, "Couldn't refresh collection list", e);
|
||||
|
||||
Intent debugIntent = new Intent(DavService.this, DebugInfoActivity.class);
|
||||
debugIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e);
|
||||
if (account != null)
|
||||
debugIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account);
|
||||
|
||||
NotificationManager nm = (NotificationManager)getSystemService(NOTIFICATION_SERVICE);
|
||||
Notification notify = new NotificationCompat.Builder(DavService.this)
|
||||
.setSmallIcon(R.drawable.ic_error_light)
|
||||
.setLargeIcon(((BitmapDrawable)getResources().getDrawable(R.drawable.ic_launcher)).getBitmap())
|
||||
.setContentTitle(getString(R.string.dav_service_refresh_failed))
|
||||
.setContentText(getString(R.string.dav_service_refresh_couldnt_refresh))
|
||||
.setContentIntent(PendingIntent.getActivity(DavService.this, 0, debugIntent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.build();
|
||||
nm.notify(Constants.NOTIFICATION_REFRESH_COLLECTIONS, notify);
|
||||
} finally {
|
||||
dbHelper.close();
|
||||
|
||||
runningRefresh.remove(service);
|
||||
for (WeakReference<RefreshingStatusListener> ref : refreshingStatusListeners) {
|
||||
RefreshingStatusListener listener = ref.get();
|
||||
if (listener != null)
|
||||
listener.onDavRefreshStatusChanged(service, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given URL defines home sets and adds them to the home set list.
|
||||
* @param serviceType CalDAV/CardDAV (calendar home set / addressbook home set)
|
||||
* @param dav DavResource to check
|
||||
* @param homeSets set where found home set URLs will be put into
|
||||
*/
|
||||
private void queryHomeSets(String serviceType, DavResource dav, Set<HttpUrl> homeSets) throws IOException, HttpException, DavException {
|
||||
if (Services.SERVICE_CARDDAV.equals(serviceType)) {
|
||||
dav.propfind(0, AddressbookHomeSet.NAME, GroupMembership.NAME);
|
||||
AddressbookHomeSet addressbookHomeSet = (AddressbookHomeSet)dav.properties.get(AddressbookHomeSet.NAME);
|
||||
if (addressbookHomeSet != null)
|
||||
for (String href : addressbookHomeSet.hrefs)
|
||||
homeSets.add(UrlUtils.withTrailingSlash(dav.location.resolve(href)));
|
||||
} else if (Services.SERVICE_CALDAV.equals(serviceType)) {
|
||||
dav.propfind(0, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME);
|
||||
CalendarHomeSet calendarHomeSet = (CalendarHomeSet)dav.properties.get(CalendarHomeSet.NAME);
|
||||
if (calendarHomeSet != null)
|
||||
for (String href : calendarHomeSet.hrefs)
|
||||
homeSets.add(UrlUtils.withTrailingSlash(dav.location.resolve(href)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@NonNull
|
||||
private Account account() {
|
||||
@Cleanup Cursor cursor = db.query(Services._TABLE, new String[] { Services.ACCOUNT_NAME }, Services.ID + "=?", new String[] { String.valueOf(service) }, null, null, null);
|
||||
if (cursor.moveToNext()) {
|
||||
return new Account(cursor.getString(0), Constants.ACCOUNT_TYPE);
|
||||
} else
|
||||
throw new IllegalArgumentException("Service not found");
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private String serviceType() {
|
||||
@Cleanup Cursor cursor = db.query(Services._TABLE, new String[] { Services.SERVICE }, Services.ID + "=?", new String[] { String.valueOf(service) }, null, null, null);
|
||||
if (cursor.moveToNext())
|
||||
return cursor.getString(0);
|
||||
else
|
||||
throw new IllegalArgumentException("Service not found");
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private HttpUrl readPrincipal() {
|
||||
@Cleanup Cursor cursor = db.query(Services._TABLE, new String[] { Services.PRINCIPAL }, Services.ID + "=?", new String[] { String.valueOf(service) }, null, null, null);
|
||||
if (cursor.moveToNext()) {
|
||||
String principal = cursor.getString(0);
|
||||
if (principal != null)
|
||||
return HttpUrl.parse(cursor.getString(0));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private Set<HttpUrl> readHomeSets() {
|
||||
Set<HttpUrl> homeSets = new LinkedHashSet<>();
|
||||
@Cleanup Cursor cursor = db.query(HomeSets._TABLE, new String[] { HomeSets.URL }, HomeSets.SERVICE_ID + "=?", new String[] { String.valueOf(service) }, null, null, null);
|
||||
while (cursor.moveToNext())
|
||||
homeSets.add(HttpUrl.parse(cursor.getString(0)));
|
||||
return homeSets;
|
||||
}
|
||||
|
||||
private void saveHomeSets(Set<HttpUrl> homeSets) {
|
||||
db.delete(HomeSets._TABLE, HomeSets.SERVICE_ID + "=?", new String[] { String.valueOf(service) });
|
||||
for (HttpUrl homeSet : homeSets) {
|
||||
ContentValues values = new ContentValues(1);
|
||||
values.put(HomeSets.SERVICE_ID, service);
|
||||
values.put(HomeSets.URL, homeSet.toString());
|
||||
db.insertOrThrow(HomeSets._TABLE, null, values);
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private Map<HttpUrl, CollectionInfo> readCollections() {
|
||||
Map<HttpUrl, CollectionInfo> collections = new LinkedHashMap<>();
|
||||
@Cleanup Cursor cursor = db.query(Collections._TABLE, null, Collections.SERVICE_ID + "=?", new String[]{String.valueOf(service)}, null, null, null);
|
||||
while (cursor.moveToNext()) {
|
||||
ContentValues values = new ContentValues();
|
||||
DatabaseUtils.cursorRowToContentValues(cursor, values);
|
||||
collections.put(HttpUrl.parse(values.getAsString(Collections.URL)), CollectionInfo.fromDB(values));
|
||||
}
|
||||
return collections;
|
||||
}
|
||||
|
||||
private void saveCollections(Iterable<CollectionInfo> collections) {
|
||||
db.delete(Collections._TABLE, HomeSets.SERVICE_ID + "=?", new String[] { String.valueOf(service) });
|
||||
for (CollectionInfo collection : collections) {
|
||||
ContentValues values = collection.toDB();
|
||||
App.log.log(Level.FINE, "Saving collection", values);
|
||||
values.put(Collections.SERVICE_ID, service);
|
||||
db.insertWithOnConflict(Collections._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
413
app/src/main/java/at/bitfire/davdroid/DavService.kt
Normal file
413
app/src/main/java/at/bitfire/davdroid/DavService.kt
Normal file
@@ -0,0 +1,413 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.Intent
|
||||
import android.database.DatabaseUtils
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.os.Binder
|
||||
import android.os.Bundle
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.*
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB.*
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.IOException
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class DavService: Service() {
|
||||
|
||||
companion object {
|
||||
const val ACTION_REFRESH_COLLECTIONS = "refreshCollections"
|
||||
const val EXTRA_DAV_SERVICE_ID = "davServiceID"
|
||||
|
||||
/** Initialize a forced synchronization. Expects intent data
|
||||
to be an URI of this format:
|
||||
contents://<authority>/<account.type>/<account name>
|
||||
**/
|
||||
const val ACTION_FORCE_SYNC = "forceSync"
|
||||
}
|
||||
|
||||
private val runningRefresh = HashSet<Long>()
|
||||
private val refreshingStatusListeners = LinkedList<WeakReference<RefreshingStatusListener>>()
|
||||
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
intent?.let {
|
||||
val id = intent.getLongExtra(EXTRA_DAV_SERVICE_ID, -1)
|
||||
|
||||
when (intent.action) {
|
||||
ACTION_REFRESH_COLLECTIONS ->
|
||||
if (runningRefresh.add(id)) {
|
||||
refreshingStatusListeners.forEach { listener ->
|
||||
listener.get()?.onDavRefreshStatusChanged(id, true)
|
||||
}
|
||||
thread { refreshCollections(id) }
|
||||
}
|
||||
|
||||
ACTION_FORCE_SYNC -> {
|
||||
val uri = intent.data!!
|
||||
val authority = uri.authority!!
|
||||
val account = Account(
|
||||
uri.pathSegments[1],
|
||||
uri.pathSegments[0]
|
||||
)
|
||||
forceSync(authority, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
|
||||
/* BOUND SERVICE PART
|
||||
for communicating with the activities
|
||||
*/
|
||||
|
||||
interface RefreshingStatusListener {
|
||||
fun onDavRefreshStatusChanged(id: Long, refreshing: Boolean)
|
||||
fun onDavRefreshFinished(id: Long)
|
||||
}
|
||||
|
||||
private val binder = InfoBinder()
|
||||
|
||||
inner class InfoBinder: Binder() {
|
||||
fun isRefreshing(id: Long) = runningRefresh.contains(id)
|
||||
|
||||
fun addRefreshingStatusListener(listener: RefreshingStatusListener, callImmediateIfRunning: Boolean) {
|
||||
refreshingStatusListeners += WeakReference<RefreshingStatusListener>(listener)
|
||||
if (callImmediateIfRunning)
|
||||
runningRefresh.forEach { id -> listener.onDavRefreshStatusChanged(id, true) }
|
||||
}
|
||||
|
||||
fun removeRefreshingStatusListener(listener: RefreshingStatusListener) {
|
||||
val iter = refreshingStatusListeners.iterator()
|
||||
while (iter.hasNext()) {
|
||||
val item = iter.next().get()
|
||||
if (listener == item)
|
||||
iter.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?) = binder
|
||||
|
||||
|
||||
|
||||
/* ACTION RUNNABLES
|
||||
which actually do the work
|
||||
*/
|
||||
|
||||
private fun forceSync(authority: String, account: Account) {
|
||||
Logger.log.info("Forcing $authority synchronization of $account")
|
||||
val extras = Bundle(2)
|
||||
extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true) // manual sync
|
||||
extras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true) // run immediately (don't queue)
|
||||
ContentResolver.requestSync(account, authority, extras)
|
||||
}
|
||||
|
||||
private fun refreshCollections(service: Long) {
|
||||
OpenHelper(this@DavService).use { dbHelper ->
|
||||
val db = dbHelper.writableDatabase
|
||||
|
||||
val serviceType by lazy {
|
||||
db.query(Services._TABLE, arrayOf(Services.SERVICE), "${Services.ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return@lazy cursor.getString(0)
|
||||
} ?: throw IllegalArgumentException("Service not found")
|
||||
}
|
||||
|
||||
val account by lazy {
|
||||
db.query(Services._TABLE, arrayOf(Services.ACCOUNT_NAME), "${Services.ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return@lazy Account(cursor.getString(0), getString(R.string.account_type))
|
||||
}
|
||||
throw IllegalArgumentException("Account not found")
|
||||
}
|
||||
|
||||
val homeSets by lazy {
|
||||
val homeSets = mutableSetOf<HttpUrl>()
|
||||
db.query(HomeSets._TABLE, arrayOf(HomeSets.URL), "${HomeSets.SERVICE_ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor ->
|
||||
while (cursor.moveToNext())
|
||||
HttpUrl.parse(cursor.getString(0))?.let { homeSets += it }
|
||||
}
|
||||
homeSets
|
||||
}
|
||||
|
||||
val collections by lazy {
|
||||
val collections = mutableMapOf<HttpUrl, CollectionInfo>()
|
||||
db.query(Collections._TABLE, null, "${Collections.SERVICE_ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val values = ContentValues(cursor.columnCount)
|
||||
DatabaseUtils.cursorRowToContentValues(cursor, values)
|
||||
values.getAsString(Collections.URL)?.let { url ->
|
||||
HttpUrl.parse(url)?.let { collections.put(it, CollectionInfo(values)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
collections
|
||||
}
|
||||
|
||||
fun readPrincipal(): HttpUrl? {
|
||||
db.query(Services._TABLE, arrayOf(Services.PRINCIPAL), "${Services.ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
cursor.getString(0)?.let { return HttpUrl.parse(it) }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given URL defines home sets and adds them to the home set list.
|
||||
*
|
||||
* @throws IOException
|
||||
* @throws HttpException
|
||||
* @throws DavException
|
||||
*/
|
||||
fun queryHomeSets(client: OkHttpClient, url: HttpUrl, recurse: Boolean = true) {
|
||||
var related = setOf<HttpUrl>()
|
||||
|
||||
fun findRelated(root: HttpUrl, dav: Response) {
|
||||
// refresh home sets: calendar-proxy-read/write-for
|
||||
dav[CalendarProxyReadFor::class.java]?.let {
|
||||
for (href in it.hrefs) {
|
||||
Logger.log.fine("Principal is a read-only proxy for $href, checking for home sets")
|
||||
root.resolve(href)?.let { proxyReadFor ->
|
||||
related += proxyReadFor
|
||||
}
|
||||
}
|
||||
}
|
||||
dav[CalendarProxyWriteFor::class.java]?.let {
|
||||
for (href in it.hrefs) {
|
||||
Logger.log.fine("Principal is a read/write proxy for $href, checking for home sets")
|
||||
root.resolve(href)?.let { proxyWriteFor ->
|
||||
related += proxyWriteFor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// refresh home sets: direct group memberships
|
||||
dav[GroupMembership::class.java]?.let {
|
||||
for (href in it.hrefs) {
|
||||
Logger.log.fine("Principal is member of group $href, checking for home sets")
|
||||
root.resolve(href)?.let { groupMembership ->
|
||||
related += groupMembership
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val dav = DavResource(client, url)
|
||||
when (serviceType) {
|
||||
Services.SERVICE_CARDDAV ->
|
||||
try {
|
||||
dav.propfind(0, AddressbookHomeSet.NAME, GroupMembership.NAME) { response, _ ->
|
||||
response[AddressbookHomeSet::class.java]?.let { homeSet ->
|
||||
for (href in homeSet.hrefs)
|
||||
dav.location.resolve(href)?.let { homeSets += UrlUtils.withTrailingSlash(it) }
|
||||
}
|
||||
|
||||
if (recurse)
|
||||
findRelated(dav.location, response)
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
if (e.code/100 == 4)
|
||||
Logger.log.log(Level.INFO, "Ignoring Client Error 4xx while looking for addressbook home sets", e)
|
||||
else
|
||||
throw e
|
||||
}
|
||||
Services.SERVICE_CALDAV -> {
|
||||
try {
|
||||
dav.propfind(0, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME) { response, _ ->
|
||||
response[CalendarHomeSet::class.java]?.let { homeSet ->
|
||||
for (href in homeSet.hrefs)
|
||||
dav.location.resolve(href)?.let { homeSets.add(UrlUtils.withTrailingSlash(it)) }
|
||||
}
|
||||
|
||||
if (recurse)
|
||||
findRelated(dav.location, response)
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
if (e.code/100 == 4)
|
||||
Logger.log.log(Level.INFO, "Ignoring Client Error 4xx while looking for calendar home sets", e)
|
||||
else
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (resource in related)
|
||||
queryHomeSets(client, resource, false)
|
||||
}
|
||||
|
||||
fun saveHomeSets() {
|
||||
db.delete(HomeSets._TABLE, "${HomeSets.SERVICE_ID}=?", arrayOf(service.toString()))
|
||||
for (homeSet in homeSets) {
|
||||
val values = ContentValues(2)
|
||||
values.put(HomeSets.SERVICE_ID, service)
|
||||
values.put(HomeSets.URL, homeSet.toString())
|
||||
db.insertOrThrow(HomeSets._TABLE, null, values)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveCollections() {
|
||||
db.delete(Collections._TABLE, "${HomeSets.SERVICE_ID}=?", arrayOf(service.toString()))
|
||||
for ((_,collection) in collections) {
|
||||
val values = collection.toDB()
|
||||
Logger.log.log(Level.FINE, "Saving collection", values)
|
||||
values.put(Collections.SERVICE_ID, service)
|
||||
db.insertWithOnConflict(Collections._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
Logger.log.info("Refreshing $serviceType collections of service #$service")
|
||||
|
||||
// cancel previous notification
|
||||
NotificationManagerCompat.from(this)
|
||||
.cancel(service.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS)
|
||||
|
||||
// create authenticating OkHttpClient (credentials taken from account settings)
|
||||
HttpClient.Builder(this, AccountSettings(this, account))
|
||||
.setForeground(true)
|
||||
.build().use { client ->
|
||||
val httpClient = client.okHttpClient
|
||||
|
||||
// refresh home set list (from principal)
|
||||
readPrincipal()?.let { principalUrl ->
|
||||
Logger.log.fine("Querying principal $principalUrl for home sets")
|
||||
queryHomeSets(httpClient, principalUrl)
|
||||
}
|
||||
|
||||
// remember selected collections
|
||||
val selectedCollections = HashSet<HttpUrl>()
|
||||
collections.values
|
||||
.filter { it.selected }
|
||||
.forEach { (url, _) -> selectedCollections += url }
|
||||
|
||||
// now refresh collections (taken from home sets)
|
||||
val itHomeSets = homeSets.iterator()
|
||||
while (itHomeSets.hasNext()) {
|
||||
val homeSetUrl = itHomeSets.next()
|
||||
Logger.log.fine("Listing home set $homeSetUrl")
|
||||
|
||||
try {
|
||||
DavResource(httpClient, homeSetUrl).propfind(1, *CollectionInfo.DAV_PROPERTIES) { response, _ ->
|
||||
if (!response.isSuccess())
|
||||
return@propfind
|
||||
|
||||
val info = CollectionInfo(response)
|
||||
info.confirmed = true
|
||||
Logger.log.log(Level.FINE, "Found collection", info)
|
||||
|
||||
if ((serviceType == Services.SERVICE_CARDDAV && info.type == CollectionInfo.Type.ADDRESS_BOOK) ||
|
||||
(serviceType == Services.SERVICE_CALDAV && arrayOf(CollectionInfo.Type.CALENDAR, CollectionInfo.Type.WEBCAL).contains(info.type)))
|
||||
collections[response.href] = info
|
||||
}
|
||||
} catch(e: HttpException) {
|
||||
if (e.code in arrayOf(403, 404, 410))
|
||||
// delete home set only if it was not accessible (40x)
|
||||
itHomeSets.remove()
|
||||
}
|
||||
}
|
||||
|
||||
// check/refresh unconfirmed collections
|
||||
val itCollections = collections.entries.iterator()
|
||||
while (itCollections.hasNext()) {
|
||||
val (url, info) = itCollections.next()
|
||||
if (!info.confirmed)
|
||||
try {
|
||||
DavResource(httpClient, url).propfind(0, *CollectionInfo.DAV_PROPERTIES) { response, _ ->
|
||||
if (!response.isSuccess())
|
||||
return@propfind
|
||||
|
||||
val collectionInfo = CollectionInfo(response)
|
||||
collectionInfo.confirmed = true
|
||||
|
||||
// remove unusable collections
|
||||
if ((serviceType == Services.SERVICE_CARDDAV && collectionInfo.type != CollectionInfo.Type.ADDRESS_BOOK) ||
|
||||
(serviceType == Services.SERVICE_CALDAV && !arrayOf(CollectionInfo.Type.CALENDAR, CollectionInfo.Type.WEBCAL).contains(collectionInfo.type)) ||
|
||||
(collectionInfo.type == CollectionInfo.Type.WEBCAL && collectionInfo.source == null))
|
||||
itCollections.remove()
|
||||
}
|
||||
} catch(e: HttpException) {
|
||||
if (e.code in arrayOf(403, 404, 410))
|
||||
// delete collection only if it was not accessible (40x)
|
||||
itCollections.remove()
|
||||
else
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// restore selections
|
||||
for (url in selectedCollections)
|
||||
collections[url]?.let { it.selected = true }
|
||||
}
|
||||
|
||||
db.beginTransactionNonExclusive()
|
||||
try {
|
||||
saveHomeSets()
|
||||
saveCollections()
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
|
||||
} catch(e: InvalidAccountException) {
|
||||
Logger.log.log(Level.SEVERE, "Invalid account", e)
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't refresh collection list", e)
|
||||
|
||||
val debugIntent = Intent(this, DebugInfoActivity::class.java)
|
||||
debugIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
|
||||
debugIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account)
|
||||
|
||||
val notify = NotificationUtils.newBuilder(this, NotificationUtils.CHANNEL_GENERAL)
|
||||
.setSmallIcon(R.drawable.ic_sync_error_notification)
|
||||
.setContentTitle(getString(R.string.dav_service_refresh_failed))
|
||||
.setContentText(getString(R.string.dav_service_refresh_couldnt_refresh))
|
||||
.setContentIntent(PendingIntent.getActivity(this, 0, debugIntent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.setSubText(account.name)
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.build()
|
||||
NotificationManagerCompat.from(this)
|
||||
.notify(service.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS, notify)
|
||||
} finally {
|
||||
runningRefresh.remove(service)
|
||||
refreshingStatusListeners.mapNotNull { it.get() }.forEach {
|
||||
it.onDavRefreshStatusChanged(service, false)
|
||||
it.onDavRefreshFinished(service)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
import okhttp3.HttpUrl;
|
||||
|
||||
public class DavUtils {
|
||||
|
||||
public static String ARGBtoCalDAVColor(int colorWithAlpha) {
|
||||
byte alpha = (byte)(colorWithAlpha >> 24);
|
||||
int color = colorWithAlpha & 0xFFFFFF;
|
||||
return String.format("#%06X%02X", color, alpha);
|
||||
}
|
||||
|
||||
public static String lastSegmentOfUrl(@NonNull String url) {
|
||||
// the list returned by HttpUrl.pathSegments() is unmodifiable, so we have to create a copy
|
||||
List<String> segments = new LinkedList<>(HttpUrl.parse(url).pathSegments());
|
||||
Collections.reverse(segments);
|
||||
|
||||
for (String segment : segments)
|
||||
if (!StringUtils.isEmpty(segment))
|
||||
return segment;
|
||||
|
||||
return "/";
|
||||
}
|
||||
|
||||
}
|
||||
104
app/src/main/java/at/bitfire/davdroid/DavUtils.kt
Normal file
104
app/src/main/java/at/bitfire/davdroid/DavUtils.kt
Normal file
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.accounts.Account
|
||||
import android.annotation.TargetApi
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import okhttp3.HttpUrl
|
||||
import org.xbill.DNS.*
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Some WebDAV and related network utility methods
|
||||
*/
|
||||
object DavUtils {
|
||||
|
||||
fun ARGBtoCalDAVColor(colorWithAlpha: Int): String {
|
||||
val alpha = (colorWithAlpha shr 24) and 0xFF
|
||||
val color = colorWithAlpha and 0xFFFFFF
|
||||
return String.format("#%06X%02X", color, alpha)
|
||||
}
|
||||
|
||||
|
||||
fun lastSegmentOfUrl(url: HttpUrl): String {
|
||||
// the list returned by HttpUrl.pathSegments() is unmodifiable, so we have to create a copy
|
||||
val segments = LinkedList<String>(url.pathSegments())
|
||||
segments.reverse()
|
||||
|
||||
return segments.firstOrNull { it.isNotEmpty() } ?: "/"
|
||||
}
|
||||
|
||||
fun prepareLookup(context: Context, lookup: Lookup) {
|
||||
@TargetApi(Build.VERSION_CODES.O)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
/* Since Android 8, the system properties net.dns1, net.dns2, ... are not available anymore.
|
||||
The current version of dnsjava relies on these properties to find the default name servers,
|
||||
so we have to add the servers explicitly (fortunately, there's an Android API to
|
||||
get the active DNS servers). */
|
||||
val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val activeLink = connectivity.getLinkProperties(connectivity.activeNetwork)
|
||||
val simpleResolvers = activeLink.dnsServers.map {
|
||||
Logger.log.fine("Using DNS server ${it.hostAddress}")
|
||||
val resolver = SimpleResolver()
|
||||
resolver.setAddress(it)
|
||||
resolver
|
||||
}
|
||||
val resolver = ExtendedResolver(simpleResolvers.toTypedArray())
|
||||
lookup.setResolver(resolver)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectSRVRecord(records: Array<Record>?): SRVRecord? {
|
||||
val srvRecords = records?.filterIsInstance(SRVRecord::class.java)
|
||||
srvRecords?.let {
|
||||
if (it.size > 1)
|
||||
Logger.log.warning("Multiple SRV records not supported yet; using first one")
|
||||
return it.firstOrNull()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun pathsFromTXTRecords(records: Array<Record>?): List<String> {
|
||||
val paths = LinkedList<String>()
|
||||
records?.filterIsInstance(TXTRecord::class.java)?.forEach { txt ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
for (segment in txt.strings as List<String>)
|
||||
if (segment.startsWith("path=")) {
|
||||
paths.add(segment.substring(5))
|
||||
break
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
|
||||
fun requestSync(context: Context, account: Account) {
|
||||
val authorities = arrayOf(
|
||||
context.getString(R.string.address_books_authority),
|
||||
CalendarContract.AUTHORITY,
|
||||
TaskProvider.ProviderName.OpenTasks.authority
|
||||
)
|
||||
|
||||
for (authority in authorities) {
|
||||
val extras = Bundle(2)
|
||||
extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true) // manual sync
|
||||
extras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true) // run immediately (don't queue)
|
||||
ContentResolver.requestSync(account, authority, extras)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import at.bitfire.dav4android.BasicDigestAuthenticator;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import okhttp3.Credentials;
|
||||
import okhttp3.Interceptor;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.logging.HttpLoggingInterceptor;
|
||||
|
||||
public class HttpClient {
|
||||
private static final OkHttpClient client = new OkHttpClient();
|
||||
private static final UserAgentInterceptor userAgentInterceptor = new UserAgentInterceptor();
|
||||
|
||||
private static final String userAgent;
|
||||
static {
|
||||
String date = new SimpleDateFormat("yyyy/MM/dd", Locale.US).format(new Date(BuildConfig.buildTime));
|
||||
userAgent = "DAVdroid/" + BuildConfig.VERSION_NAME + " (" + date + "; dav4android; okhttp3) Android/" + Build.VERSION.RELEASE;
|
||||
}
|
||||
|
||||
private HttpClient() {
|
||||
}
|
||||
|
||||
public static OkHttpClient create(@NonNull Context context, @NonNull Account account, @NonNull final Logger logger) throws InvalidAccountException {
|
||||
OkHttpClient.Builder builder = defaultBuilder(logger);
|
||||
|
||||
// use account settings for authentication and logging
|
||||
AccountSettings settings = new AccountSettings(context, account);
|
||||
|
||||
if (settings.preemptiveAuth())
|
||||
builder.addNetworkInterceptor(new PreemptiveAuthenticationInterceptor(settings.username(), settings.password()));
|
||||
else
|
||||
builder.authenticator(new BasicDigestAuthenticator(null, settings.username(), settings.password()));
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public static OkHttpClient create(@NonNull Logger logger) {
|
||||
return defaultBuilder(logger).build();
|
||||
}
|
||||
|
||||
public static OkHttpClient create(@NonNull Context context, @NonNull Account account) throws InvalidAccountException {
|
||||
return create(context, account, App.log);
|
||||
}
|
||||
|
||||
public static OkHttpClient create() {
|
||||
return create(App.log);
|
||||
}
|
||||
|
||||
private static OkHttpClient.Builder defaultBuilder(@NonNull final Logger logger) {
|
||||
OkHttpClient.Builder builder = client.newBuilder();
|
||||
|
||||
// use MemorizingTrustManager to manage self-signed certificates
|
||||
if (App.getSslSocketFactoryCompat() != null)
|
||||
builder.sslSocketFactory(App.getSslSocketFactoryCompat(), App.getMemorizingTrustManager());
|
||||
if (App.getHostnameVerifier() != null)
|
||||
builder.hostnameVerifier(App.getHostnameVerifier());
|
||||
|
||||
// set timeouts
|
||||
builder.connectTimeout(30, TimeUnit.SECONDS);
|
||||
builder.writeTimeout(30, TimeUnit.SECONDS);
|
||||
builder.readTimeout(120, TimeUnit.SECONDS);
|
||||
|
||||
// don't allow redirects, because it would break PROPFIND handling
|
||||
builder.followRedirects(false);
|
||||
|
||||
// add User-Agent to every request
|
||||
builder.addNetworkInterceptor(userAgentInterceptor);
|
||||
|
||||
// add cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
|
||||
builder.cookieJar(new MemoryCookieStore());
|
||||
|
||||
// add network logging, if requested
|
||||
if (logger.isLoggable(Level.FINEST)) {
|
||||
HttpLoggingInterceptor loggingInterceptor = new HttpLoggingInterceptor(new HttpLoggingInterceptor.Logger() {
|
||||
@Override
|
||||
public void log(String message) {
|
||||
logger.finest(message);
|
||||
}
|
||||
});
|
||||
loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
|
||||
builder.addInterceptor(loggingInterceptor);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static OkHttpClient.Builder addAuthentication(@NonNull OkHttpClient.Builder builder, @NonNull String username, @NonNull String password, boolean preemptive) {
|
||||
if (preemptive)
|
||||
builder.addNetworkInterceptor(new PreemptiveAuthenticationInterceptor(username, password));
|
||||
else
|
||||
builder.authenticator(new BasicDigestAuthenticator(null, username, password));
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static OkHttpClient addAuthentication(@NonNull OkHttpClient client, @NonNull String username, @NonNull String password, boolean preemptive) {
|
||||
OkHttpClient.Builder builder = client.newBuilder();
|
||||
addAuthentication(builder, username, password, preemptive);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public static OkHttpClient addAuthentication(@NonNull OkHttpClient client, @NonNull String host, @NonNull String username, @NonNull String password) {
|
||||
return client.newBuilder()
|
||||
.authenticator(new BasicDigestAuthenticator(host, username, password))
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
static class UserAgentInterceptor implements Interceptor {
|
||||
@Override
|
||||
public Response intercept(Chain chain) throws IOException {
|
||||
Locale locale = Locale.getDefault();
|
||||
Request request = chain.request().newBuilder()
|
||||
.header("User-Agent", userAgent)
|
||||
.header("Accept-Language", locale.getLanguage() + "-" + locale.getCountry() + ", " + locale.getLanguage() + ";q=0.7, *;q=0.5")
|
||||
.build();
|
||||
return chain.proceed(request);
|
||||
}
|
||||
}
|
||||
|
||||
@RequiredArgsConstructor
|
||||
static class PreemptiveAuthenticationInterceptor implements Interceptor {
|
||||
final String username, password;
|
||||
|
||||
@Override
|
||||
public Response intercept(Chain chain) throws IOException {
|
||||
App.log.fine("Adding basic authorization header for user " + username);
|
||||
Request request = chain.request().newBuilder()
|
||||
.header("Authorization", Credentials.basic(username, password))
|
||||
.build();
|
||||
return chain.proceed(request);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
249
app/src/main/java/at/bitfire/davdroid/HttpClient.kt
Normal file
249
app/src/main/java/at/bitfire/davdroid/HttpClient.kt
Normal file
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.security.KeyChain
|
||||
import at.bitfire.cert4android.CertTlsSocketFactory
|
||||
import at.bitfire.cert4android.CustomCertManager
|
||||
import at.bitfire.dav4jvm.BasicDigestAuthHandler
|
||||
import at.bitfire.dav4jvm.Constants
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.Credentials
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import okhttp3.Cache
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
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.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Level
|
||||
import javax.net.ssl.KeyManager
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509ExtendedKeyManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
class HttpClient private constructor(
|
||||
val okHttpClient: OkHttpClient,
|
||||
private val certManager: CustomCertManager?
|
||||
): AutoCloseable {
|
||||
|
||||
companion object {
|
||||
/** max. size of disk cache (10 MB) */
|
||||
const val DISK_CACHE_MAX_SIZE: Long = 10*1024*1024
|
||||
|
||||
/** [OkHttpClient] singleton to build all clients from */
|
||||
val sharedClient: OkHttpClient = OkHttpClient.Builder()
|
||||
// set timeouts
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
|
||||
// don't allow redirects by default, because it would break PROPFIND handling
|
||||
.followRedirects(false)
|
||||
|
||||
// add User-Agent to every request
|
||||
.addNetworkInterceptor(UserAgentInterceptor)
|
||||
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
override fun close() {
|
||||
okHttpClient.cache()?.close()
|
||||
certManager?.close()
|
||||
}
|
||||
|
||||
class Builder(
|
||||
val context: Context? = null,
|
||||
accountSettings: AccountSettings? = null,
|
||||
val logger: java.util.logging.Logger = Logger.log
|
||||
) {
|
||||
private var certManager: CustomCertManager? = null
|
||||
private var certificateAlias: String? = null
|
||||
private var cache: Cache? = null
|
||||
|
||||
private val orig = sharedClient.newBuilder()
|
||||
|
||||
init {
|
||||
// add cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
|
||||
orig.cookieJar(MemoryCookieStore())
|
||||
|
||||
// add network logging, if requested
|
||||
if (logger.isLoggable(Level.FINEST)) {
|
||||
val loggingInterceptor = HttpLoggingInterceptor(HttpLoggingInterceptor.Logger {
|
||||
message -> logger.finest(message)
|
||||
})
|
||||
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
|
||||
orig.addInterceptor(loggingInterceptor)
|
||||
}
|
||||
|
||||
context?.let {
|
||||
val settings = Settings.getInstance(context)
|
||||
|
||||
// custom proxy support
|
||||
try {
|
||||
if (settings.getBoolean(Settings.OVERRIDE_PROXY) == true) {
|
||||
val address = InetSocketAddress(
|
||||
settings.getString(Settings.OVERRIDE_PROXY_HOST)
|
||||
?: Settings.OVERRIDE_PROXY_HOST_DEFAULT,
|
||||
settings.getInt(Settings.OVERRIDE_PROXY_PORT)
|
||||
?: Settings.OVERRIDE_PROXY_PORT_DEFAULT
|
||||
)
|
||||
|
||||
val proxy = Proxy(Proxy.Type.HTTP, address)
|
||||
orig.proxy(proxy)
|
||||
Logger.log.log(Level.INFO, "Using proxy", proxy)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Can't set proxy, ignoring", e)
|
||||
}
|
||||
|
||||
//if (BuildConfig.customCerts)
|
||||
customCertManager(CustomCertManager(context, true,
|
||||
!(settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES)
|
||||
?: Settings.DISTRUST_SYSTEM_CERTIFICATES_DEFAULT)))
|
||||
}
|
||||
|
||||
// use account settings for authentication
|
||||
accountSettings?.let {
|
||||
addAuthentication(null, it.credentials())
|
||||
}
|
||||
}
|
||||
|
||||
constructor(context: Context, host: String?, credentials: Credentials): this(context) {
|
||||
addAuthentication(host, credentials)
|
||||
}
|
||||
|
||||
fun withDiskCache(): Builder {
|
||||
val context = context ?: throw IllegalArgumentException("Context is required to find the cache directory")
|
||||
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 followRedirects(follow: Boolean): Builder {
|
||||
orig.followRedirects(follow)
|
||||
return this
|
||||
}
|
||||
|
||||
fun customCertManager(manager: CustomCertManager) {
|
||||
certManager = manager
|
||||
}
|
||||
fun setForeground(foreground: Boolean): Builder {
|
||||
certManager?.appInForeground = foreground
|
||||
return this
|
||||
}
|
||||
|
||||
fun addAuthentication(host: String?, credentials: Credentials): Builder {
|
||||
when (credentials.type) {
|
||||
Credentials.Type.UsernamePassword -> {
|
||||
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), credentials.userName!!, credentials.password!!)
|
||||
orig .addNetworkInterceptor(authHandler)
|
||||
.authenticator(authHandler)
|
||||
}
|
||||
Credentials.Type.ClientCertificate -> {
|
||||
certificateAlias = credentials.certificateAlias
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun build(): HttpClient {
|
||||
val trustManager = certManager ?: {
|
||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
factory.init(null as KeyStore?)
|
||||
factory.trustManagers.first() as X509TrustManager
|
||||
}()
|
||||
|
||||
val hostnameVerifier = certManager?.hostnameVerifier(OkHostnameVerifier.INSTANCE)
|
||||
?: OkHostnameVerifier.INSTANCE
|
||||
|
||||
var keyManager: KeyManager? = null
|
||||
try {
|
||||
certificateAlias?.let { alias ->
|
||||
val context = requireNotNull(context)
|
||||
|
||||
// get client certificate and private key
|
||||
val certs = KeyChain.getCertificateChain(context, alias) ?: return@let
|
||||
val key = KeyChain.getPrivateKey(context, alias) ?: return@let
|
||||
logger.fine("Using client certificate $alias for authentication (chain length: ${certs.size})")
|
||||
|
||||
// create Android KeyStore (performs key operations without revealing secret data to DAVx5)
|
||||
val keyStore = KeyStore.getInstance("AndroidKeyStore")
|
||||
keyStore.load(null)
|
||||
|
||||
// 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 }
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.SEVERE, "Couldn't set up client certificate authentication", e)
|
||||
}
|
||||
|
||||
orig.sslSocketFactory(CertTlsSocketFactory(keyManager, trustManager), trustManager)
|
||||
orig.hostnameVerifier(hostnameVerifier)
|
||||
|
||||
return HttpClient(orig.build(), certManager)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
private object UserAgentInterceptor: Interceptor {
|
||||
// use Locale.US because numbers may be encoded as non-ASCII characters in other locales
|
||||
private val userAgentDateFormat = SimpleDateFormat("yyyy/MM/dd", Locale.US)
|
||||
private val userAgentDate = userAgentDateFormat.format(Date(BuildConfig.buildTime))
|
||||
private val userAgent = "${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4jvm; okhttp/${Constants.okhttpVersion}) Android/${Build.VERSION.RELEASE}"
|
||||
|
||||
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,19 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2016 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import android.accounts.Account;
|
||||
|
||||
public class InvalidAccountException extends Exception {
|
||||
|
||||
public InvalidAccountException(Account account) {
|
||||
super("Invalid account: " + account);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.accounts.Account
|
||||
|
||||
class InvalidAccountException(account: Account): Exception("Invalid account: $account")
|
||||
@@ -1,69 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2016 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import org.apache.commons.collections4.MapIterator;
|
||||
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;
|
||||
import java.util.List;
|
||||
|
||||
import okhttp3.Cookie;
|
||||
import okhttp3.CookieJar;
|
||||
import okhttp3.HttpUrl;
|
||||
|
||||
/**
|
||||
* Primitive cookie store that stores cookies in a (volatile) hash map.
|
||||
* Will be sufficient for session cookies.
|
||||
*/
|
||||
public class MemoryCookieStore implements 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!
|
||||
*/
|
||||
protected final MultiKeyMap<String, Cookie> storage = MultiKeyMap.multiKeyMap(new HashedMap<MultiKey<? extends String>, Cookie>());
|
||||
|
||||
@Override
|
||||
public void saveFromResponse(HttpUrl url, List<Cookie> cookies) {
|
||||
synchronized(storage) {
|
||||
for (Cookie cookie : cookies)
|
||||
storage.put(cookie.name(), cookie.domain(), cookie.path(), cookie);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Cookie> loadForRequest(HttpUrl url) {
|
||||
List<Cookie> cookies = new LinkedList<>();
|
||||
|
||||
synchronized(storage) {
|
||||
MapIterator<MultiKey<? extends String>, Cookie> iter = storage.mapIterator();
|
||||
while (iter.hasNext()) {
|
||||
iter.next();
|
||||
Cookie cookie = iter.getValue();
|
||||
|
||||
// remove expired cookies
|
||||
if (cookie.expiresAt() <= System.currentTimeMillis()) {
|
||||
iter.remove();
|
||||
continue;
|
||||
}
|
||||
|
||||
// add applicable cookies
|
||||
if (cookie.matches(url))
|
||||
cookies.add(cookie);
|
||||
}
|
||||
}
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
}
|
||||
63
app/src/main/java/at/bitfire/davdroid/MemoryCookieStore.kt
Normal file
63
app/src/main/java/at/bitfire/davdroid/MemoryCookieStore.kt
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
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.*
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.ServiceDB.Services
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.ical4android.TaskProvider.ProviderName.OpenTasks
|
||||
|
||||
class PackageChangedReceiver: BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
|
||||
fun updateTaskSync(context: Context) {
|
||||
val tasksInstalled = LocalTaskList.tasksProviderAvailable(context)
|
||||
Logger.log.info("Tasks provider available = $tasksInstalled")
|
||||
|
||||
// check all accounts and (de)activate OpenTasks if a CalDAV service is defined
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.readableDatabase
|
||||
|
||||
db.query(Services._TABLE, arrayOf(Services.ACCOUNT_NAME),
|
||||
"${Services.SERVICE}=?", arrayOf(Services.SERVICE_CALDAV), null, null, null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val account = Account(cursor.getString(0), context.getString(R.string.account_type))
|
||||
|
||||
if (tasksInstalled) {
|
||||
if (ContentResolver.getIsSyncable(account, OpenTasks.authority) <= 0) {
|
||||
ContentResolver.setIsSyncable(account, OpenTasks.authority, 1)
|
||||
ContentResolver.addPeriodicSync(account, OpenTasks.authority, Bundle(), Constants.DEFAULT_SYNC_INTERVAL)
|
||||
}
|
||||
} else
|
||||
ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 0)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
updateTaskSync(context)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import android.os.Build;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.Socket;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
|
||||
import de.duenndns.ssl.MemorizingTrustManager;
|
||||
import lombok.Cleanup;
|
||||
|
||||
public class SSLSocketFactoryCompat extends SSLSocketFactory {
|
||||
|
||||
private SSLSocketFactory delegate;
|
||||
|
||||
// Android 5.0+ (API level21) provides reasonable default settings
|
||||
// but it still allows SSLv3
|
||||
// https://developer.android.com/reference/javax/net/ssl/SSLSocket.html
|
||||
static String protocols[] = null, cipherSuites[] = null;
|
||||
static {
|
||||
try {
|
||||
@Cleanup SSLSocket socket = (SSLSocket)SSLSocketFactory.getDefault().createSocket();
|
||||
if (socket != null) {
|
||||
/* set reasonable protocol versions */
|
||||
// - enable all supported protocols (enables TLSv1.1 and TLSv1.2 on Android <5.0)
|
||||
// - remove all SSL versions (especially SSLv3) because they're insecure now
|
||||
List<String> protocols = new LinkedList<>();
|
||||
for (String protocol : socket.getSupportedProtocols())
|
||||
if (!protocol.toUpperCase(Locale.US).contains("SSL"))
|
||||
protocols.add(protocol);
|
||||
App.log.info("Setting allowed TLS protocols: " + TextUtils.join(", ", protocols));
|
||||
SSLSocketFactoryCompat.protocols = protocols.toArray(new String[protocols.size()]);
|
||||
|
||||
/* set up reasonable cipher suites */
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||
// choose known secure cipher suites
|
||||
List<String> allowedCiphers = Arrays.asList(
|
||||
// TLS 1.2
|
||||
"TLS_RSA_WITH_AES_256_GCM_SHA384",
|
||||
"TLS_RSA_WITH_AES_128_GCM_SHA256",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
|
||||
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256",
|
||||
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
|
||||
// maximum interoperability
|
||||
"TLS_RSA_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_RSA_WITH_AES_128_CBC_SHA",
|
||||
// additionally
|
||||
"TLS_RSA_WITH_AES_256_CBC_SHA",
|
||||
"TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA",
|
||||
"TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA",
|
||||
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA");
|
||||
List<String> availableCiphers = Arrays.asList(socket.getSupportedCipherSuites());
|
||||
App.log.info("Available cipher suites: " + TextUtils.join(", ", availableCiphers));
|
||||
App.log.info("Cipher suites enabled by default: " + TextUtils.join(", ", socket.getEnabledCipherSuites()));
|
||||
|
||||
// take all allowed ciphers that are available and put them into preferredCiphers
|
||||
HashSet<String> preferredCiphers = new HashSet<>(allowedCiphers);
|
||||
preferredCiphers.retainAll(availableCiphers);
|
||||
|
||||
/* For maximum security, preferredCiphers should *replace* enabled ciphers (thus disabling
|
||||
* ciphers which are enabled by default, but have become unsecure), but I guess for
|
||||
* the security level of DAVdroid and maximum compatibility, disabling of insecure
|
||||
* ciphers should be a server-side task */
|
||||
|
||||
// add preferred ciphers to enabled ciphers
|
||||
HashSet<String> enabledCiphers = preferredCiphers;
|
||||
enabledCiphers.addAll(new HashSet<>(Arrays.asList(socket.getEnabledCipherSuites())));
|
||||
|
||||
App.log.info("Enabling (only) those TLS ciphers: " + TextUtils.join(", ", enabledCiphers));
|
||||
SSLSocketFactoryCompat.cipherSuites = enabledCiphers.toArray(new String[enabledCiphers.size()]);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
App.log.severe("Couldn't determine default TLS settings");
|
||||
}
|
||||
}
|
||||
|
||||
public SSLSocketFactoryCompat(@NonNull MemorizingTrustManager mtm) {
|
||||
try {
|
||||
SSLContext sslContext = SSLContext.getInstance("TLS");
|
||||
sslContext.init(null, new X509TrustManager[] { mtm }, null);
|
||||
delegate = sslContext.getSocketFactory();
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new AssertionError(); // The system has no TLS. Just give up.
|
||||
}
|
||||
}
|
||||
|
||||
private void upgradeTLS(SSLSocket ssl) {
|
||||
if (protocols != null)
|
||||
ssl.setEnabledProtocols(protocols);
|
||||
|
||||
if (cipherSuites != null)
|
||||
ssl.setEnabledCipherSuites(cipherSuites);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public String[] getDefaultCipherSuites() {
|
||||
return cipherSuites;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] getSupportedCipherSuites() {
|
||||
return cipherSuites;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException {
|
||||
Socket ssl = delegate.createSocket(s, host, port, autoClose);
|
||||
if (ssl instanceof SSLSocket)
|
||||
upgradeTLS((SSLSocket)ssl);
|
||||
return ssl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(String host, int port) throws IOException {
|
||||
Socket ssl = delegate.createSocket(host, port);
|
||||
if (ssl instanceof SSLSocket)
|
||||
upgradeTLS((SSLSocket)ssl);
|
||||
return ssl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException {
|
||||
Socket ssl = delegate.createSocket(host, port, localHost, localPort);
|
||||
if (ssl instanceof SSLSocket)
|
||||
upgradeTLS((SSLSocket)ssl);
|
||||
return ssl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(InetAddress host, int port) throws IOException {
|
||||
Socket ssl = delegate.createSocket(host, port);
|
||||
if (ssl instanceof SSLSocket)
|
||||
upgradeTLS((SSLSocket)ssl);
|
||||
return ssl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException {
|
||||
Socket ssl = delegate.createSocket(address, port, localAddress, localPort);
|
||||
if (ssl instanceof SSLSocket)
|
||||
upgradeTLS((SSLSocket)ssl);
|
||||
return ssl;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2016 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
|
||||
*/
|
||||
|
||||
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;
|
||||
|
||||
public class LogcatHandler extends Handler {
|
||||
private static final int MAX_LINE_LENGTH = 3000;
|
||||
public static final LogcatHandler INSTANCE = new LogcatHandler();
|
||||
|
||||
private LogcatHandler() {
|
||||
super();
|
||||
setFormatter(PlainTextFormatter.LOGCAT);
|
||||
setLevel(Level.ALL);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void publish(LogRecord r) {
|
||||
String text = getFormatter().format(r);
|
||||
int level = r.getLevel().intValue();
|
||||
|
||||
int end = text.length();
|
||||
for (int pos = 0; pos < end; pos += MAX_LINE_LENGTH) {
|
||||
String line = text.substring(pos, NumberUtils.min(pos + MAX_LINE_LENGTH, end));
|
||||
|
||||
if (level >= Level.SEVERE.intValue())
|
||||
Log.e(r.getLoggerName(), line);
|
||||
else if (level >= Level.WARNING.intValue())
|
||||
Log.w(r.getLoggerName(), line);
|
||||
else if (level >= Level.CONFIG.intValue())
|
||||
Log.i(r.getLoggerName(), line);
|
||||
else if (level >= Level.FINER.intValue())
|
||||
Log.d(r.getLoggerName(), line);
|
||||
else
|
||||
Log.v(r.getLoggerName(), line);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void flush() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
}
|
||||
|
||||
}
|
||||
50
app/src/main/java/at/bitfire/davdroid/log/LogcatHandler.kt
Normal file
50
app/src/main/java/at/bitfire/davdroid/log/LogcatHandler.kt
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
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() {}
|
||||
|
||||
}
|
||||
137
app/src/main/java/at/bitfire/davdroid/log/Logger.kt
Normal file
137
app/src/main/java/at/bitfire/davdroid/log/Logger.kt
Normal file
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.preference.PreferenceManager
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.AppSettingsActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.logging.FileHandler
|
||||
import java.util.logging.Level
|
||||
|
||||
@SuppressLint("StaticFieldLeak") // we'll only keep an app context
|
||||
object Logger : SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
private const val LOG_TO_FILE = "log_to_file"
|
||||
|
||||
val log = java.util.logging.Logger.getLogger("davx5")!!
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var preferences: SharedPreferences
|
||||
|
||||
fun initialize(someContext: Context) {
|
||||
context = someContext.applicationContext
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
private fun reinitialize() {
|
||||
val logToFile = preferences.getBoolean(LOG_TO_FILE, false)
|
||||
val logVerbose = logToFile || 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
|
||||
|
||||
// remove all handlers and add our own logcat handler
|
||||
rootLogger.useParentHandlers = false
|
||||
rootLogger.handlers.forEach { rootLogger.removeHandler(it) }
|
||||
rootLogger.addHandler(LogcatHandler)
|
||||
|
||||
val nm = NotificationManagerCompat.from(context)
|
||||
// log to external file according to preferences
|
||||
if (logToFile) {
|
||||
val builder = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_DEBUG)
|
||||
builder .setSmallIcon(R.drawable.ic_sd_storage_notification)
|
||||
.setContentTitle(context.getString(R.string.logging_notification_title))
|
||||
|
||||
val logDir = debugDir(context) ?: return
|
||||
val logFile = File(logDir, "davx5-log.txt")
|
||||
|
||||
try {
|
||||
val fileHandler = FileHandler(logFile.toString(), true)
|
||||
fileHandler.formatter = PlainTextFormatter.DEFAULT
|
||||
rootLogger.addHandler(fileHandler)
|
||||
|
||||
val prefIntent = Intent(context, AppSettingsActivity::class.java)
|
||||
prefIntent.putExtra(AppSettingsActivity.EXTRA_SCROLL_TO, LOG_TO_FILE)
|
||||
prefIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
builder .setContentText(logDir.path)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setContentText(context.getString(R.string.logging_notification_text))
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0, prefIntent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.setOngoing(true)
|
||||
|
||||
// add "Share" action
|
||||
val logFileUri = FileProvider.getUriForFile(context, context.getString(R.string.authority_debug_provider), logFile)
|
||||
log.fine("Now logging to file: $logFile -> $logFileUri")
|
||||
|
||||
val shareIntent = Intent(Intent.ACTION_SEND)
|
||||
shareIntent.putExtra(Intent.EXTRA_SUBJECT, "DAVx⁵ logs")
|
||||
shareIntent.putExtra(Intent.EXTRA_STREAM, logFileUri)
|
||||
shareIntent.type = "text/plain"
|
||||
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
val chooserIntent = Intent.createChooser(shareIntent, null)
|
||||
val shareAction = NotificationCompat.Action.Builder(R.drawable.ic_share_action_notification,
|
||||
context.getString(R.string.logging_notification_send_log),
|
||||
PendingIntent.getActivity(context, 0, chooserIntent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
builder.addAction(shareAction.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()
|
||||
}
|
||||
|
||||
nm.notify(NotificationUtils.NOTIFY_EXTERNAL_FILE_LOGGING, builder.build())
|
||||
} else {
|
||||
nm.cancel(NotificationUtils.NOTIFY_EXTERNAL_FILE_LOGGING)
|
||||
|
||||
// delete old logs
|
||||
debugDir(context)?.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun debugDir(context: Context): 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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2016 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
|
||||
*/
|
||||
|
||||
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.logging.Formatter;
|
||||
import java.util.logging.LogRecord;
|
||||
|
||||
public class PlainTextFormatter extends Formatter {
|
||||
public final static PlainTextFormatter
|
||||
LOGCAT = new PlainTextFormatter(true),
|
||||
DEFAULT = new PlainTextFormatter(false);
|
||||
|
||||
private final boolean logcat;
|
||||
|
||||
private PlainTextFormatter(boolean onLogcat) {
|
||||
this.logcat = onLogcat;
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("ThrowableResultOfMethodCallIgnored")
|
||||
public String format(LogRecord r) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
|
||||
if (!logcat)
|
||||
builder .append(DateFormatUtils.format(r.getMillis(), "yyyy-MM-dd HH:mm:ss"))
|
||||
.append(" ").append(r.getThreadID()).append(" ");
|
||||
|
||||
builder.append(String.format("[%s] %s", shortClassName(r.getSourceClassName()), r.getMessage()));
|
||||
|
||||
if (r.getThrown() != null)
|
||||
builder .append("\nEXCEPTION ")
|
||||
.append(ExceptionUtils.getStackTrace(r.getThrown()));
|
||||
|
||||
if (r.getParameters() != null) {
|
||||
int idx = 1;
|
||||
for (Object param : r.getParameters())
|
||||
builder.append("\n\tPARAMETER #").append(idx++).append(" = ").append(param);
|
||||
}
|
||||
|
||||
if (!logcat)
|
||||
builder.append("\n");
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String shortClassName(String className) {
|
||||
String s = StringUtils.replace(className, "at.bitfire.davdroid.", "");
|
||||
return StringUtils.replace(s, "at.bitfire.", "");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
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.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"))
|
||||
.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,40 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2016 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.log;
|
||||
|
||||
import java.util.logging.Handler;
|
||||
import java.util.logging.LogRecord;
|
||||
|
||||
public class StringHandler extends Handler {
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
|
||||
public StringHandler() {
|
||||
super();
|
||||
setFormatter(PlainTextFormatter.DEFAULT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void publish(LogRecord record) {
|
||||
builder.append(getFormatter().format(record));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void flush() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
31
app/src/main/java/at/bitfire/davdroid/log/StringHandler.kt
Normal file
31
app/src/main/java/at/bitfire/davdroid/log/StringHandler.kt
Normal file
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
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,163 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2016 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model;
|
||||
|
||||
import android.content.ContentValues;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import at.bitfire.dav4android.DavResource;
|
||||
import at.bitfire.dav4android.Property;
|
||||
import at.bitfire.dav4android.property.AddressbookDescription;
|
||||
import at.bitfire.dav4android.property.CalendarColor;
|
||||
import at.bitfire.dav4android.property.CalendarDescription;
|
||||
import at.bitfire.dav4android.property.CalendarTimezone;
|
||||
import at.bitfire.dav4android.property.CurrentUserPrivilegeSet;
|
||||
import at.bitfire.dav4android.property.DisplayName;
|
||||
import at.bitfire.dav4android.property.ResourceType;
|
||||
import at.bitfire.dav4android.property.SupportedAddressData;
|
||||
import at.bitfire.dav4android.property.SupportedCalendarComponentSet;
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections;
|
||||
import lombok.ToString;
|
||||
|
||||
@ToString
|
||||
public class CollectionInfo implements Serializable {
|
||||
public long id;
|
||||
public Long serviceID;
|
||||
|
||||
public enum Type {
|
||||
ADDRESS_BOOK,
|
||||
CALENDAR
|
||||
}
|
||||
public Type type;
|
||||
|
||||
public String url;
|
||||
|
||||
public boolean readOnly;
|
||||
public String displayName, description;
|
||||
public Integer color;
|
||||
|
||||
public String timeZone;
|
||||
public Boolean supportsVEVENT;
|
||||
public Boolean supportsVTODO;
|
||||
|
||||
public boolean selected;
|
||||
|
||||
// non-persistent properties
|
||||
public boolean confirmed;
|
||||
|
||||
|
||||
public static final Property.Name[] DAV_PROPERTIES = {
|
||||
ResourceType.NAME,
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
DisplayName.NAME,
|
||||
AddressbookDescription.NAME, SupportedAddressData.NAME,
|
||||
CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME
|
||||
};
|
||||
|
||||
public static CollectionInfo fromDavResource(DavResource dav) {
|
||||
CollectionInfo info = new CollectionInfo();
|
||||
info.url = dav.location.toString();
|
||||
|
||||
ResourceType type = (ResourceType)dav.properties.get(ResourceType.NAME);
|
||||
if (type != null) {
|
||||
if (type.types.contains(ResourceType.ADDRESSBOOK))
|
||||
info.type = Type.ADDRESS_BOOK;
|
||||
else if (type.types.contains(ResourceType.CALENDAR))
|
||||
info.type = Type.CALENDAR;
|
||||
}
|
||||
|
||||
info.readOnly = false;
|
||||
CurrentUserPrivilegeSet privilegeSet = (CurrentUserPrivilegeSet)dav.properties.get(CurrentUserPrivilegeSet.NAME);
|
||||
if (privilegeSet != null)
|
||||
info.readOnly = !privilegeSet.mayWriteContent;
|
||||
|
||||
DisplayName displayName = (DisplayName)dav.properties.get(DisplayName.NAME);
|
||||
if (displayName != null && !StringUtils.isEmpty(displayName.displayName))
|
||||
info.displayName = displayName.displayName;
|
||||
|
||||
if (info.type == Type.ADDRESS_BOOK) {
|
||||
AddressbookDescription addressbookDescription = (AddressbookDescription)dav.properties.get(AddressbookDescription.NAME);
|
||||
if (addressbookDescription != null)
|
||||
info.description = addressbookDescription.description;
|
||||
|
||||
} else if (info.type == Type.CALENDAR) {
|
||||
CalendarDescription calendarDescription = (CalendarDescription)dav.properties.get(CalendarDescription.NAME);
|
||||
if (calendarDescription != null)
|
||||
info.description = calendarDescription.description;
|
||||
|
||||
CalendarColor calendarColor = (CalendarColor)dav.properties.get(CalendarColor.NAME);
|
||||
if (calendarColor != null)
|
||||
info.color = calendarColor.color;
|
||||
|
||||
CalendarTimezone timeZone = (CalendarTimezone)dav.properties.get(CalendarTimezone.NAME);
|
||||
if (timeZone != null)
|
||||
info.timeZone = timeZone.vTimeZone;
|
||||
|
||||
info.supportsVEVENT = info.supportsVTODO = true;
|
||||
SupportedCalendarComponentSet supportedCalendarComponentSet = (SupportedCalendarComponentSet)dav.properties.get(SupportedCalendarComponentSet.NAME);
|
||||
if (supportedCalendarComponentSet != null) {
|
||||
info.supportsVEVENT = supportedCalendarComponentSet.supportsEvents;
|
||||
info.supportsVTODO = supportedCalendarComponentSet.supportsTasks;
|
||||
}
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
|
||||
public static CollectionInfo fromDB(ContentValues values) {
|
||||
CollectionInfo info = new CollectionInfo();
|
||||
info.id = values.getAsLong(Collections.ID);
|
||||
info.serviceID = values.getAsLong(Collections.SERVICE_ID);
|
||||
|
||||
info.url = values.getAsString(Collections.URL);
|
||||
info.readOnly = values.getAsInteger(Collections.READ_ONLY) != 0;
|
||||
info.displayName = values.getAsString(Collections.DISPLAY_NAME);
|
||||
info.description = values.getAsString(Collections.DESCRIPTION);
|
||||
|
||||
info.color = values.getAsInteger(Collections.COLOR);
|
||||
|
||||
info.timeZone = values.getAsString(Collections.TIME_ZONE);
|
||||
info.supportsVEVENT = getAsBooleanOrNull(values, Collections.SUPPORTS_VEVENT);
|
||||
info.supportsVTODO = getAsBooleanOrNull(values, Collections.SUPPORTS_VTODO);
|
||||
|
||||
info.selected = values.getAsInteger(Collections.SYNC) != 0;
|
||||
return info;
|
||||
}
|
||||
|
||||
public ContentValues toDB() {
|
||||
ContentValues values = new ContentValues();
|
||||
// Collections.SERVICE_ID is never changed
|
||||
|
||||
values.put(Collections.URL, url);
|
||||
values.put(Collections.READ_ONLY, readOnly ? 1 : 0);
|
||||
values.put(Collections.DISPLAY_NAME, displayName);
|
||||
values.put(Collections.DESCRIPTION, description);
|
||||
values.put(Collections.COLOR, color);
|
||||
|
||||
values.put(Collections.TIME_ZONE, timeZone);
|
||||
if (supportsVEVENT != null)
|
||||
values.put(Collections.SUPPORTS_VEVENT, supportsVEVENT ? 1 : 0);
|
||||
if (supportsVTODO != null)
|
||||
values.put(Collections.SUPPORTS_VTODO, supportsVTODO ? 1 : 0);
|
||||
|
||||
values.put(Collections.SYNC, selected ? 1 : 0);
|
||||
return values;
|
||||
}
|
||||
|
||||
|
||||
private static Boolean getAsBooleanOrNull(ContentValues values, String field) {
|
||||
Integer i = values.getAsInteger(field);
|
||||
return (i == null) ? null : (i != 0);
|
||||
}
|
||||
|
||||
}
|
||||
259
app/src/main/java/at/bitfire/davdroid/model/CollectionInfo.kt
Normal file
259
app/src/main/java/at/bitfire/davdroid/model/CollectionInfo.kt
Normal file
@@ -0,0 +1,259 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.dav4jvm.property.*
|
||||
import at.bitfire.davdroid.DavUtils
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import at.bitfire.ical4android.MiscUtils
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
/**
|
||||
* Represents a WebDAV collection.
|
||||
*
|
||||
* @constructor always appends a trailing slash to the URL
|
||||
*/
|
||||
data class CollectionInfo(
|
||||
|
||||
/**
|
||||
* URL of the collection (including trailing slash)
|
||||
*/
|
||||
val url: HttpUrl,
|
||||
|
||||
var id: Long? = null,
|
||||
var serviceID: Long? = null,
|
||||
|
||||
var type: Type? = null,
|
||||
|
||||
var privWriteContent: Boolean = true,
|
||||
var privUnbind: Boolean = true,
|
||||
var forceReadOnly: Boolean = false,
|
||||
var displayName: String? = null,
|
||||
var description: String? = null,
|
||||
var color: Int? = null,
|
||||
|
||||
var timeZone: String? = null,
|
||||
var supportsVEVENT: Boolean = false,
|
||||
var supportsVTODO: Boolean = false,
|
||||
var supportsVJOURNAL: Boolean = false,
|
||||
var selected: Boolean = false,
|
||||
|
||||
// subscriptions
|
||||
var source: String? = null,
|
||||
|
||||
// non-persistent properties
|
||||
var confirmed: Boolean = false,
|
||||
var uiEnabled: Boolean = true
|
||||
): Parcelable {
|
||||
|
||||
enum class Type {
|
||||
ADDRESS_BOOK,
|
||||
CALENDAR,
|
||||
WEBCAL // iCalendar subscription
|
||||
}
|
||||
|
||||
constructor(dav: Response): this(UrlUtils.withTrailingSlash(dav.href)) {
|
||||
dav[ResourceType::class.java]?.let { type ->
|
||||
when {
|
||||
type.types.contains(ResourceType.ADDRESSBOOK) -> this.type = Type.ADDRESS_BOOK
|
||||
type.types.contains(ResourceType.CALENDAR) -> this.type = Type.CALENDAR
|
||||
type.types.contains(ResourceType.SUBSCRIBED) -> this.type = Type.WEBCAL
|
||||
}
|
||||
}
|
||||
|
||||
dav[CurrentUserPrivilegeSet::class.java]?.let { privilegeSet ->
|
||||
privWriteContent = privilegeSet.mayWriteContent
|
||||
privUnbind = privilegeSet.mayUnbind
|
||||
}
|
||||
|
||||
dav[DisplayName::class.java]?.let {
|
||||
if (!it.displayName.isNullOrEmpty())
|
||||
displayName = it.displayName
|
||||
}
|
||||
|
||||
when (type) {
|
||||
Type.ADDRESS_BOOK -> {
|
||||
dav[AddressbookDescription::class.java]?.let { description = it.description }
|
||||
}
|
||||
Type.CALENDAR, Type.WEBCAL -> {
|
||||
dav[CalendarDescription::class.java]?.let { description = it.description }
|
||||
dav[CalendarColor::class.java]?.let { color = it.color }
|
||||
dav[CalendarTimezone::class.java]?.let { timeZone = it.vTimeZone }
|
||||
|
||||
if (type == Type.CALENDAR) {
|
||||
supportsVEVENT = true
|
||||
supportsVTODO = true
|
||||
dav[SupportedCalendarComponentSet::class.java]?.let {
|
||||
supportsVEVENT = it.supportsEvents
|
||||
supportsVTODO = it.supportsTasks
|
||||
}
|
||||
} else { // Type.WEBCAL
|
||||
dav[Source::class.java]?.let { source = it.hrefs.firstOrNull() }
|
||||
supportsVEVENT = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
constructor(values: ContentValues): this(UrlUtils.withTrailingSlash(HttpUrl.parse(values.getAsString(Collections.URL))!!)) {
|
||||
id = values.getAsLong(Collections.ID)
|
||||
serviceID = values.getAsLong(Collections.SERVICE_ID)
|
||||
type = try {
|
||||
Type.valueOf(values.getAsString(Collections.TYPE))
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
privWriteContent = values.getAsInteger(Collections.PRIV_WRITE_CONTENT) != 0
|
||||
privUnbind = values.getAsInteger(Collections.PRIV_UNBIND) != 0
|
||||
forceReadOnly = values.getAsInteger(Collections.FORCE_READ_ONLY) != 0
|
||||
displayName = values.getAsString(Collections.DISPLAY_NAME)
|
||||
description = values.getAsString(Collections.DESCRIPTION)
|
||||
|
||||
color = values.getAsInteger(Collections.COLOR)
|
||||
|
||||
timeZone = values.getAsString(Collections.TIME_ZONE)
|
||||
supportsVEVENT = getAsBooleanOrNull(values, Collections.SUPPORTS_VEVENT) ?: false
|
||||
supportsVTODO = getAsBooleanOrNull(values, Collections.SUPPORTS_VTODO) ?: false
|
||||
|
||||
source = values.getAsString(Collections.SOURCE)
|
||||
|
||||
selected = values.getAsInteger(Collections.SYNC) != 0
|
||||
}
|
||||
|
||||
fun toDB(): ContentValues {
|
||||
val values = ContentValues()
|
||||
// Collections.SERVICE_ID is never changed
|
||||
type?.let { values.put(Collections.TYPE, it.name) }
|
||||
|
||||
values.put(Collections.URL, url.toString())
|
||||
values.put(Collections.PRIV_WRITE_CONTENT, if (privWriteContent) 1 else 0)
|
||||
values.put(Collections.PRIV_UNBIND, if (privUnbind) 1 else 0)
|
||||
values.put(Collections.FORCE_READ_ONLY, if (forceReadOnly) 1 else 0)
|
||||
values.put(Collections.DISPLAY_NAME, displayName)
|
||||
values.put(Collections.DESCRIPTION, description)
|
||||
values.put(Collections.COLOR, color)
|
||||
|
||||
values.put(Collections.TIME_ZONE, timeZone)
|
||||
values.put(Collections.SUPPORTS_VEVENT, if (supportsVEVENT) 1 else 0)
|
||||
values.put(Collections.SUPPORTS_VTODO, if (supportsVTODO) 1 else 0)
|
||||
|
||||
values.put(Collections.SOURCE, source)
|
||||
|
||||
values.put(Collections.SYNC, if (selected) 1 else 0)
|
||||
return values
|
||||
}
|
||||
|
||||
fun title() = displayName ?: DavUtils.lastSegmentOfUrl(url)
|
||||
|
||||
|
||||
private fun getAsBooleanOrNull(values: ContentValues, field: String): Boolean? {
|
||||
val i = values.getAsInteger(field)
|
||||
return if (i == null)
|
||||
null
|
||||
else
|
||||
(i != 0)
|
||||
}
|
||||
|
||||
|
||||
override fun describeContents(): Int = 0
|
||||
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||
fun<T> writeOrNull(value: T?, write: (T) -> Unit) {
|
||||
if (value == null)
|
||||
dest.writeByte(0)
|
||||
else {
|
||||
dest.writeByte(1)
|
||||
write(value)
|
||||
}
|
||||
}
|
||||
|
||||
dest.writeString(url.toString())
|
||||
|
||||
writeOrNull(id) { dest.writeLong(it) }
|
||||
writeOrNull(serviceID) { dest.writeLong(it) }
|
||||
|
||||
dest.writeString(type?.name)
|
||||
|
||||
dest.writeByte(if (privWriteContent) 1 else 0)
|
||||
dest.writeByte(if (privUnbind) 1 else 0)
|
||||
|
||||
dest.writeByte(if (forceReadOnly) 1 else 0)
|
||||
dest.writeString(displayName)
|
||||
dest.writeString(description)
|
||||
writeOrNull(color) { dest.writeInt(it) }
|
||||
|
||||
dest.writeString(timeZone)
|
||||
dest.writeByte(if (supportsVEVENT) 1 else 0)
|
||||
dest.writeByte(if (supportsVTODO) 1 else 0)
|
||||
dest.writeByte(if (supportsVJOURNAL) 1 else 0)
|
||||
dest.writeByte(if (selected) 1 else 0)
|
||||
|
||||
dest.writeString(source)
|
||||
|
||||
dest.writeByte(if (confirmed) 1 else 0)
|
||||
}
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<CollectionInfo> {
|
||||
|
||||
val DAV_PROPERTIES = arrayOf(
|
||||
ResourceType.NAME,
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
DisplayName.NAME,
|
||||
AddressbookDescription.NAME, SupportedAddressData.NAME,
|
||||
CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME,
|
||||
Source.NAME
|
||||
)
|
||||
|
||||
override fun createFromParcel(parcel: Parcel): CollectionInfo {
|
||||
fun<T> readOrNull(parcel: Parcel, read: () -> T): T? {
|
||||
return if (parcel.readByte() == 0.toByte())
|
||||
null
|
||||
else
|
||||
read()
|
||||
}
|
||||
|
||||
return CollectionInfo(
|
||||
HttpUrl.parse(parcel.readString()!!)!!,
|
||||
|
||||
readOrNull(parcel) { parcel.readLong() },
|
||||
readOrNull(parcel) { parcel.readLong() },
|
||||
|
||||
parcel.readString()?.let { Type.valueOf(it) },
|
||||
|
||||
parcel.readByte() != 0.toByte(),
|
||||
parcel.readByte() != 0.toByte(),
|
||||
|
||||
parcel.readByte() != 0.toByte(),
|
||||
parcel.readString(),
|
||||
parcel.readString(),
|
||||
readOrNull(parcel) { parcel.readInt() },
|
||||
|
||||
parcel.readString(),
|
||||
parcel.readByte() != 0.toByte(),
|
||||
parcel.readByte() != 0.toByte(),
|
||||
parcel.readByte() != 0.toByte(),
|
||||
parcel.readByte() != 0.toByte(),
|
||||
|
||||
parcel.readString(),
|
||||
|
||||
parcel.readByte() != 0.toByte()
|
||||
)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int) = arrayOfNulls<CollectionInfo>(size)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
38
app/src/main/java/at/bitfire/davdroid/model/Credentials.kt
Normal file
38
app/src/main/java/at/bitfire/davdroid/model/Credentials.kt
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
class Credentials(
|
||||
val userName: String? = null,
|
||||
val password: String? = null,
|
||||
val certificateAlias: String? = null
|
||||
) {
|
||||
|
||||
enum class Type {
|
||||
UsernamePassword,
|
||||
ClientCertificate
|
||||
}
|
||||
|
||||
val type: Type
|
||||
|
||||
init {
|
||||
type = when {
|
||||
!certificateAlias.isNullOrEmpty() ->
|
||||
Type.ClientCertificate
|
||||
!userName.isNullOrEmpty() && !password.isNullOrEmpty() ->
|
||||
Type.UsernamePassword
|
||||
else ->
|
||||
throw IllegalArgumentException("Either username/password or certificate alias must be set")
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString() =
|
||||
"Credentials(type=$type, userName=$userName, certificateAlias=$certificateAlias)"
|
||||
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2016 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
import android.database.sqlite.SQLiteException;
|
||||
import android.database.sqlite.SQLiteOpenHelper;
|
||||
import android.os.Build;
|
||||
|
||||
import at.bitfire.davdroid.App;
|
||||
import lombok.Cleanup;
|
||||
|
||||
public class ServiceDB {
|
||||
|
||||
public static class Settings {
|
||||
public static final String
|
||||
_TABLE = "settings",
|
||||
NAME = "setting",
|
||||
VALUE = "value";
|
||||
}
|
||||
|
||||
public static class Services {
|
||||
public static final String
|
||||
_TABLE = "services",
|
||||
ID = "_id",
|
||||
ACCOUNT_NAME = "accountName",
|
||||
SERVICE = "service",
|
||||
PRINCIPAL = "principal";
|
||||
|
||||
// allowed values for SERVICE column
|
||||
public static final String
|
||||
SERVICE_CALDAV = "caldav",
|
||||
SERVICE_CARDDAV = "carddav";
|
||||
}
|
||||
|
||||
public static class HomeSets {
|
||||
public static final String
|
||||
_TABLE = "homesets",
|
||||
ID = "_id",
|
||||
SERVICE_ID = "serviceID",
|
||||
URL = "url";
|
||||
}
|
||||
|
||||
public static class Collections {
|
||||
public static final String
|
||||
_TABLE = "collections",
|
||||
ID = "_id",
|
||||
SERVICE_ID = "serviceID",
|
||||
URL = "url",
|
||||
READ_ONLY = "readOnly",
|
||||
DISPLAY_NAME = "displayName",
|
||||
DESCRIPTION = "description",
|
||||
COLOR = "color",
|
||||
TIME_ZONE = "timezone",
|
||||
SUPPORTS_VEVENT = "supportsVEVENT",
|
||||
SUPPORTS_VTODO = "supportsVTODO",
|
||||
SYNC = "sync";
|
||||
}
|
||||
|
||||
|
||||
public static class OpenHelper extends SQLiteOpenHelper {
|
||||
private static final String DATABASE_NAME = "services.db";
|
||||
private static final int DATABASE_VERSION = 1;
|
||||
|
||||
public OpenHelper(Context context) {
|
||||
super(context, DATABASE_NAME, null, DATABASE_VERSION);
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN)
|
||||
setWriteAheadLoggingEnabled(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onOpen(SQLiteDatabase db) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN)
|
||||
db.setForeignKeyConstraintsEnabled(true);
|
||||
else {
|
||||
if (!db.enableWriteAheadLogging())
|
||||
App.log.warning("Couldn't enable write-ahead logging");
|
||||
|
||||
db.execSQL("PRAGMA foreign_keys=ON;");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(SQLiteDatabase db) {
|
||||
App.log.info("Creating database " + db.getPath());
|
||||
|
||||
db.execSQL("CREATE TABLE " + Settings._TABLE + "(" +
|
||||
Settings.NAME + " TEXT NOT NULL," +
|
||||
Settings.VALUE + " TEXT NOT NULL" +
|
||||
")");
|
||||
db.execSQL("CREATE UNIQUE INDEX settings_name ON " + Settings._TABLE + " (" + Settings.NAME + ")");
|
||||
|
||||
db.execSQL("CREATE TABLE " + Services._TABLE + "(" +
|
||||
Services.ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
|
||||
Services.ACCOUNT_NAME + " TEXT NOT NULL," +
|
||||
Services.SERVICE + " TEXT NOT NULL," +
|
||||
Services.PRINCIPAL + " TEXT NULL" +
|
||||
")");
|
||||
db.execSQL("CREATE UNIQUE INDEX services_account ON " + Services._TABLE + " (" + Services.ACCOUNT_NAME + "," + Services.SERVICE + ")");
|
||||
|
||||
db.execSQL("CREATE TABLE " + HomeSets._TABLE + "(" +
|
||||
HomeSets.ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
|
||||
HomeSets.SERVICE_ID + " INTEGER NOT NULL REFERENCES " + Services._TABLE +" ON DELETE CASCADE," +
|
||||
HomeSets.URL + " TEXT NOT NULL" +
|
||||
")");
|
||||
db.execSQL("CREATE UNIQUE INDEX homesets_service_url ON " + HomeSets._TABLE + "(" + HomeSets.SERVICE_ID + "," + HomeSets.URL + ")");
|
||||
|
||||
db.execSQL("CREATE TABLE " + Collections._TABLE + "(" +
|
||||
Collections.ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
|
||||
Collections.SERVICE_ID + " INTEGER NOT NULL REFERENCES " + Services._TABLE +" ON DELETE CASCADE," +
|
||||
Collections.URL + " TEXT NOT NULL," +
|
||||
Collections.READ_ONLY + " INTEGER DEFAULT 0 NOT NULL," +
|
||||
Collections.DISPLAY_NAME + " TEXT NULL," +
|
||||
Collections.DESCRIPTION + " TEXT NULL," +
|
||||
Collections.COLOR + " INTEGER NULL," +
|
||||
Collections.TIME_ZONE + " TEXT NULL," +
|
||||
Collections.SUPPORTS_VEVENT + " INTEGER NULL," +
|
||||
Collections.SUPPORTS_VTODO + " INTEGER NULL," +
|
||||
Collections.SYNC + " INTEGER DEFAULT 0 NOT NULL" +
|
||||
")");
|
||||
db.execSQL("CREATE UNIQUE INDEX collections_service_url ON " + Collections._TABLE + "(" + Collections.SERVICE_ID + "," + Collections.URL + ")");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
|
||||
}
|
||||
|
||||
|
||||
public void dump(StringBuilder sb) {
|
||||
SQLiteDatabase db = getReadableDatabase();
|
||||
db.beginTransactionNonExclusive();
|
||||
|
||||
// iterate through all tables
|
||||
@Cleanup Cursor cursorTables = db.query("sqlite_master", new String[] { "name" }, "type='table'", null, null, null, null);
|
||||
while (cursorTables.moveToNext()) {
|
||||
String table = cursorTables.getString(0);
|
||||
sb.append(table).append("\n");
|
||||
@Cleanup Cursor cursor = db.query(table, null, null, null, null, null, null);
|
||||
|
||||
// print columns
|
||||
int cols = cursor.getColumnCount();
|
||||
sb.append("\t| ");
|
||||
for (int i = 0; i < cols; i++) {
|
||||
sb.append(" ");
|
||||
sb.append(cursor.getColumnName(i));
|
||||
sb.append(" |");
|
||||
}
|
||||
sb.append("\n");
|
||||
|
||||
// print rows
|
||||
while (cursor.moveToNext()) {
|
||||
sb.append("\t| ");
|
||||
for (int i = 0; i < cols; i++) {
|
||||
sb.append(" ");
|
||||
try {
|
||||
String value = cursor.getString(i);
|
||||
if (value != null)
|
||||
sb.append(value
|
||||
.replace("\r", "<CR>")
|
||||
.replace("\n", "<LF>"));
|
||||
else
|
||||
sb.append("<null>");
|
||||
|
||||
} catch (SQLiteException e) {
|
||||
sb.append("<unprintable>");
|
||||
}
|
||||
sb.append(" |");
|
||||
}
|
||||
sb.append("\n");
|
||||
}
|
||||
sb.append("----------\n");
|
||||
}
|
||||
db.endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
237
app/src/main/java/at/bitfire/davdroid/model/ServiceDB.kt
Normal file
237
app/src/main/java/at/bitfire/davdroid/model/ServiceDB.kt
Normal file
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteException
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import android.preference.PreferenceManager
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.ui.StartupDialogFragment
|
||||
import java.util.logging.Level
|
||||
|
||||
@Suppress("ObjectPropertyName")
|
||||
class ServiceDB {
|
||||
|
||||
object Services {
|
||||
const val _TABLE = "services"
|
||||
const val ID = "_id"
|
||||
const val ACCOUNT_NAME = "accountName"
|
||||
const val SERVICE = "service"
|
||||
const val PRINCIPAL = "principal"
|
||||
|
||||
// allowed values for SERVICE column
|
||||
const val SERVICE_CALDAV = "caldav"
|
||||
const val SERVICE_CARDDAV = "carddav"
|
||||
}
|
||||
|
||||
object HomeSets {
|
||||
const val _TABLE = "homesets"
|
||||
const val ID = "_id"
|
||||
const val SERVICE_ID = "serviceID"
|
||||
const val URL = "url"
|
||||
}
|
||||
|
||||
object Collections {
|
||||
const val _TABLE = "collections"
|
||||
const val ID = "_id"
|
||||
const val TYPE = "type"
|
||||
const val SERVICE_ID = "serviceID"
|
||||
const val URL = "url"
|
||||
const val PRIV_WRITE_CONTENT = "privWriteContent"
|
||||
const val PRIV_UNBIND = "privUnbind"
|
||||
const val FORCE_READ_ONLY = "forceReadOnly"
|
||||
const val DISPLAY_NAME = "displayName"
|
||||
const val DESCRIPTION = "description"
|
||||
const val COLOR = "color"
|
||||
const val TIME_ZONE = "timezone"
|
||||
const val SUPPORTS_VEVENT = "supportsVEVENT"
|
||||
const val SUPPORTS_VTODO = "supportsVTODO"
|
||||
const val SOURCE = "source"
|
||||
const val SYNC = "sync"
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun onRenameAccount(db: SQLiteDatabase, oldName: String, newName: String) {
|
||||
val values = ContentValues(1)
|
||||
values.put(Services.ACCOUNT_NAME, newName)
|
||||
db.updateWithOnConflict(Services._TABLE, values, Services.ACCOUNT_NAME + "=?", arrayOf(oldName), SQLiteDatabase.CONFLICT_REPLACE)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class OpenHelper(
|
||||
val context: Context
|
||||
): SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION), AutoCloseable {
|
||||
|
||||
companion object {
|
||||
const val DATABASE_NAME = "services.db"
|
||||
const val DATABASE_VERSION = 5
|
||||
}
|
||||
|
||||
override fun onConfigure(db: SQLiteDatabase) {
|
||||
setWriteAheadLoggingEnabled(true)
|
||||
db.setForeignKeyConstraintsEnabled(true)
|
||||
}
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
Logger.log.info("Creating database " + db.path)
|
||||
|
||||
db.execSQL("CREATE TABLE ${Services._TABLE}(" +
|
||||
"${Services.ID} INTEGER PRIMARY KEY AUTOINCREMENT," +
|
||||
"${Services.ACCOUNT_NAME} TEXT NOT NULL," +
|
||||
"${Services.SERVICE} TEXT NOT NULL," +
|
||||
"${Services.PRINCIPAL} TEXT NULL)")
|
||||
db.execSQL("CREATE UNIQUE INDEX services_account ON ${Services._TABLE} (${Services.ACCOUNT_NAME},${Services.SERVICE})")
|
||||
|
||||
db.execSQL("CREATE TABLE ${HomeSets._TABLE}(" +
|
||||
"${HomeSets.ID} INTEGER PRIMARY KEY AUTOINCREMENT," +
|
||||
"${HomeSets.SERVICE_ID} INTEGER NOT NULL REFERENCES ${Services._TABLE} ON DELETE CASCADE," +
|
||||
"${HomeSets.URL} TEXT NOT NULL)")
|
||||
db.execSQL("CREATE UNIQUE INDEX homesets_service_url ON ${HomeSets._TABLE}(${HomeSets.SERVICE_ID},${HomeSets.URL})")
|
||||
|
||||
db.execSQL("CREATE TABLE ${Collections._TABLE}(" +
|
||||
"${Collections.ID} INTEGER PRIMARY KEY AUTOINCREMENT," +
|
||||
"${Collections.SERVICE_ID} INTEGER NOT NULL REFERENCES ${Services._TABLE} ON DELETE CASCADE," +
|
||||
"${Collections.TYPE} TEXT NOT NULL," +
|
||||
"${Collections.URL} TEXT NOT NULL," +
|
||||
"${Collections.PRIV_WRITE_CONTENT} INTEGER DEFAULT 0 NOT NULL," +
|
||||
"${Collections.PRIV_UNBIND} INTEGER DEFAULT 0 NOT NULL," +
|
||||
"${Collections.FORCE_READ_ONLY} INTEGER DEFAULT 0 NOT NULL," +
|
||||
"${Collections.DISPLAY_NAME} TEXT NULL," +
|
||||
"${Collections.DESCRIPTION} TEXT NULL," +
|
||||
"${Collections.COLOR} INTEGER NULL," +
|
||||
"${Collections.TIME_ZONE} TEXT NULL," +
|
||||
"${Collections.SUPPORTS_VEVENT} INTEGER NULL," +
|
||||
"${Collections.SUPPORTS_VTODO} INTEGER NULL," +
|
||||
"${Collections.SOURCE} TEXT NULL," +
|
||||
"${Collections.SYNC} INTEGER DEFAULT 0 NOT NULL)")
|
||||
db.execSQL("CREATE UNIQUE INDEX collections_service_url ON ${Collections._TABLE}(${Collections.SERVICE_ID},${Collections.URL})")
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
for (upgradeFrom in oldVersion until newVersion) {
|
||||
val upgradeTo = upgradeFrom + 1
|
||||
Logger.log.info("Upgrading database from version $upgradeFrom to $upgradeTo")
|
||||
try {
|
||||
val upgradeProc = this::class.java.getDeclaredMethod("upgrade_${upgradeFrom}_$upgradeTo", SQLiteDatabase::class.java)
|
||||
upgradeProc.invoke(this, db)
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't upgrade database", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun upgrade_4_5(db: SQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE ${Collections._TABLE} ADD COLUMN ${Collections.PRIV_WRITE_CONTENT} INTEGER DEFAULT 0 NOT NULL")
|
||||
db.execSQL("UPDATE ${Collections._TABLE} SET ${Collections.PRIV_WRITE_CONTENT}=NOT readOnly")
|
||||
|
||||
db.execSQL("ALTER TABLE ${Collections._TABLE} ADD COLUMN ${Collections.PRIV_UNBIND} INTEGER DEFAULT 0 NOT NULL")
|
||||
db.execSQL("UPDATE ${Collections._TABLE} SET ${Collections.PRIV_UNBIND}=NOT readOnly")
|
||||
|
||||
// there's no DROP COLUMN in SQLite, so just keep the "readOnly" column
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun upgrade_3_4(db: SQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE ${Collections._TABLE} ADD COLUMN ${Collections.FORCE_READ_ONLY} INTEGER DEFAULT 0 NOT NULL")
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun upgrade_2_3(db: SQLiteDatabase) {
|
||||
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(Settings.DISTRUST_SYSTEM_CERTIFICATES, cursor.getInt(1) != 0)
|
||||
"overrideProxy" -> edit.putBoolean(Settings.OVERRIDE_PROXY, cursor.getInt(1) != 0)
|
||||
"overrideProxyHost" -> edit.putString(Settings.OVERRIDE_PROXY_HOST, cursor.getString(1))
|
||||
"overrideProxyPort" -> edit.putInt(Settings.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()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun upgrade_1_2(db: SQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE ${Collections._TABLE} ADD COLUMN ${Collections.TYPE} TEXT NOT NULL DEFAULT ''")
|
||||
db.execSQL("ALTER TABLE ${Collections._TABLE} ADD COLUMN ${Collections.SOURCE} TEXT NULL")
|
||||
db.execSQL("UPDATE ${Collections._TABLE} SET ${Collections.TYPE}=(" +
|
||||
"SELECT CASE ${Services.SERVICE} WHEN ? THEN ? ELSE ? END " +
|
||||
"FROM ${Services._TABLE} WHERE ${Services.ID}=${Collections._TABLE}.${Collections.SERVICE_ID}" +
|
||||
")",
|
||||
arrayOf(Services.SERVICE_CALDAV, CollectionInfo.Type.CALENDAR, CollectionInfo.Type.ADDRESS_BOOK))
|
||||
}
|
||||
|
||||
|
||||
fun dump(sb: StringBuilder) {
|
||||
val db = readableDatabase
|
||||
db.beginTransactionNonExclusive()
|
||||
|
||||
// iterate through all tables
|
||||
db.query("sqlite_master", arrayOf("name"), "type='table'", null, null, null, null).use { cursorTables ->
|
||||
while (cursorTables.moveToNext()) {
|
||||
val table = cursorTables.getString(0)
|
||||
sb.append(table).append("\n")
|
||||
db.query(table, null, null, null, null, null, null).use { cursor ->
|
||||
// print columns
|
||||
val cols = cursor.columnCount
|
||||
sb.append("\t| ")
|
||||
for (i in 0 until cols)
|
||||
sb .append(" ")
|
||||
.append(cursor.getColumnName(i))
|
||||
.append(" |")
|
||||
sb.append("\n")
|
||||
|
||||
// print rows
|
||||
while (cursor.moveToNext()) {
|
||||
sb.append("\t| ")
|
||||
for (i in 0 until cols) {
|
||||
sb.append(" ")
|
||||
try {
|
||||
val value = cursor.getString(i)
|
||||
if (value != null)
|
||||
sb.append(value
|
||||
.replace("\r", "<CR>")
|
||||
.replace("\n", "<LF>"))
|
||||
else
|
||||
sb.append("<null>")
|
||||
|
||||
} catch (e: SQLiteException) {
|
||||
sb.append("<unprintable>")
|
||||
}
|
||||
sb.append(" |")
|
||||
}
|
||||
sb.append("\n")
|
||||
}
|
||||
sb.append("----------\n")
|
||||
}
|
||||
}
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2016 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.database.Cursor;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
|
||||
import lombok.Cleanup;
|
||||
|
||||
public class Settings {
|
||||
|
||||
final SQLiteDatabase db;
|
||||
|
||||
public Settings(SQLiteDatabase db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
public boolean getBoolean(String name, boolean defaultValue) {
|
||||
@Cleanup Cursor cursor = db.query(ServiceDB.Settings._TABLE, new String[] { ServiceDB.Settings.VALUE },
|
||||
ServiceDB.Settings.NAME + "=?", new String[] { name }, null, null, null);
|
||||
if (cursor.moveToNext())
|
||||
return cursor.getInt(0) != 0;
|
||||
else
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
public void putBoolean(String name, boolean value) {
|
||||
ContentValues values = new ContentValues(2);
|
||||
values.put(ServiceDB.Settings.NAME, name);
|
||||
values.put(ServiceDB.Settings.VALUE, value ? 1 : 0);
|
||||
db.insertWithOnConflict(ServiceDB.Settings._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE);
|
||||
}
|
||||
|
||||
public void remove(String name) {
|
||||
db.delete(ServiceDB.Settings._TABLE, ServiceDB.Settings.NAME + "=?", new String[] { name });
|
||||
}
|
||||
|
||||
}
|
||||
63
app/src/main/java/at/bitfire/davdroid/model/SyncState.kt
Normal file
63
app/src/main/java/at/bitfire/davdroid/model/SyncState.kt
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import at.bitfire.dav4jvm.property.SyncToken
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
data class SyncState(
|
||||
val type: Type,
|
||||
val value: String,
|
||||
|
||||
/**
|
||||
* Whether this sync state occurred during an initial sync as described
|
||||
* in RFC 6578, which means the initial sync is not complete yet.
|
||||
*/
|
||||
var initialSync: Boolean? = null
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
private const val KEY_TYPE = "type"
|
||||
private const val KEY_VALUE = "value"
|
||||
private const val KEY_INITIAL_SYNC = "initialSync"
|
||||
|
||||
fun fromString(s: String?): SyncState? {
|
||||
if (s == null)
|
||||
return null
|
||||
|
||||
return try {
|
||||
val json = JSONObject(s)
|
||||
SyncState(
|
||||
Type.valueOf(json.getString(KEY_TYPE)),
|
||||
json.getString(KEY_VALUE),
|
||||
try { json.getBoolean(KEY_INITIAL_SYNC) } catch(e: JSONException) { null }
|
||||
)
|
||||
} catch (e: JSONException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun fromSyncToken(token: SyncToken, initialSync: Boolean? = null) =
|
||||
SyncState(Type.SYNC_TOKEN, requireNotNull(token.token), initialSync)
|
||||
|
||||
}
|
||||
|
||||
enum class Type { CTAG, SYNC_TOKEN }
|
||||
|
||||
override fun toString(): String {
|
||||
val json = JSONObject()
|
||||
json.put(KEY_TYPE, type.name)
|
||||
json.put(KEY_VALUE, value)
|
||||
initialSync?.let { json.put(KEY_INITIAL_SYNC, it) }
|
||||
return json.toString()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2016 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model;
|
||||
|
||||
import android.provider.ContactsContract.RawContacts;
|
||||
|
||||
public class UnknownProperties {
|
||||
|
||||
public static final String CONTENT_ITEM_TYPE = "x.davdroid/unknown-properties";
|
||||
|
||||
public static final String
|
||||
MIMETYPE = RawContacts.Data.MIMETYPE,
|
||||
RAW_CONTACT_ID = RawContacts.Data.RAW_CONTACT_ID,
|
||||
UNKNOWN_PROPERTIES = RawContacts.Data.DATA1;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import android.provider.ContactsContract.RawContacts
|
||||
|
||||
object UnknownProperties {
|
||||
|
||||
const val CONTENT_ITEM_TYPE = "x.davdroid/unknown-properties"
|
||||
|
||||
const val MIMETYPE = RawContacts.Data.MIMETYPE
|
||||
const val RAW_CONTACT_ID = RawContacts.Data.RAW_CONTACT_ID
|
||||
const val UNKNOWN_PROPERTIES = RawContacts.Data.DATA1
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import at.bitfire.vcard4android.Contact
|
||||
|
||||
interface LocalAddress: LocalResource<Contact> {
|
||||
|
||||
fun resetDeleted()
|
||||
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 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
|
||||
*/
|
||||
package at.bitfire.davdroid.resource;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.content.ContentProviderClient;
|
||||
import android.content.ContentUris;
|
||||
import android.content.ContentValues;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.os.Parcel;
|
||||
import android.os.RemoteException;
|
||||
import android.provider.ContactsContract.Groups;
|
||||
import android.provider.ContactsContract.RawContacts;
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
import at.bitfire.vcard4android.AndroidAddressBook;
|
||||
import at.bitfire.vcard4android.AndroidContact;
|
||||
import at.bitfire.vcard4android.AndroidGroup;
|
||||
import at.bitfire.vcard4android.ContactsStorageException;
|
||||
import lombok.Cleanup;
|
||||
|
||||
|
||||
public class LocalAddressBook extends AndroidAddressBook implements LocalCollection {
|
||||
|
||||
protected static final String
|
||||
SYNC_STATE_CTAG = "ctag",
|
||||
SYNC_STATE_URL = "url";
|
||||
|
||||
private final Bundle syncState = new Bundle();
|
||||
|
||||
/**
|
||||
* Whether contact groups (LocalGroup resources) are included in query results for
|
||||
* {@link #getAll()}, {@link #getDeleted()}, {@link #getDirty()} and
|
||||
* {@link #getWithoutFileName()}.
|
||||
*/
|
||||
public boolean includeGroups = true;
|
||||
|
||||
|
||||
public LocalAddressBook(Account account, ContentProviderClient provider) {
|
||||
super(account, provider, LocalGroup.Factory.INSTANCE, LocalContact.Factory.INSTANCE);
|
||||
}
|
||||
|
||||
public LocalContact findContactByUID(String uid) throws ContactsStorageException, FileNotFoundException {
|
||||
LocalContact[] contacts = (LocalContact[])queryContacts(LocalContact.COLUMN_UID + "=?", new String[] { uid });
|
||||
if (contacts.length == 0)
|
||||
throw new FileNotFoundException();
|
||||
return contacts[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalResource[] getAll() throws ContactsStorageException {
|
||||
List<LocalResource> all = new LinkedList<>();
|
||||
Collections.addAll(all, (LocalResource[])queryContacts(null, null));
|
||||
if (includeGroups)
|
||||
Collections.addAll(all, (LocalResource[])queryGroups(null, null));
|
||||
return all.toArray(new LocalResource[all.size()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of local contacts/groups which have been deleted locally. (DELETED != 0).
|
||||
*/
|
||||
@Override
|
||||
public LocalResource[] getDeleted() throws ContactsStorageException {
|
||||
List<LocalResource> deleted = new LinkedList<>();
|
||||
Collections.addAll(deleted, getDeletedContacts());
|
||||
if (includeGroups)
|
||||
Collections.addAll(deleted, getDeletedGroups());
|
||||
return deleted.toArray(new LocalResource[deleted.size()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of local contacts/groups which have been changed locally (DIRTY != 0).
|
||||
*/
|
||||
@Override
|
||||
public LocalResource[] getDirty() throws ContactsStorageException {
|
||||
List<LocalResource> dirty = new LinkedList<>();
|
||||
Collections.addAll(dirty, getDirtyContacts());
|
||||
if (includeGroups)
|
||||
Collections.addAll(dirty, getDirtyGroups());
|
||||
return dirty.toArray(new LocalResource[dirty.size()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of local contacts which don't have a file name yet.
|
||||
*/
|
||||
@Override
|
||||
public LocalResource[] getWithoutFileName() throws ContactsStorageException {
|
||||
List<LocalResource> nameless = new LinkedList<>();
|
||||
Collections.addAll(nameless, (LocalContact[])queryContacts(AndroidContact.COLUMN_FILENAME + " IS NULL", null));
|
||||
if (includeGroups)
|
||||
Collections.addAll(nameless, (LocalGroup[])queryGroups(AndroidGroup.COLUMN_FILENAME + " IS NULL", null));
|
||||
return nameless.toArray(new LocalResource[nameless.size()]);
|
||||
}
|
||||
|
||||
public void deleteAll() throws ContactsStorageException {
|
||||
try {
|
||||
provider.delete(syncAdapterURI(RawContacts.CONTENT_URI), null, null);
|
||||
provider.delete(syncAdapterURI(Groups.CONTENT_URI), null, null);
|
||||
} catch(RemoteException e) {
|
||||
throw new ContactsStorageException("Couldn't delete all local contacts and groups", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public LocalContact[] getDeletedContacts() throws ContactsStorageException {
|
||||
return (LocalContact[])queryContacts(RawContacts.DELETED + "!= 0", null);
|
||||
}
|
||||
|
||||
public LocalContact[] getDirtyContacts() throws ContactsStorageException {
|
||||
return (LocalContact[])queryContacts(RawContacts.DIRTY + "!= 0", null);
|
||||
}
|
||||
|
||||
public LocalGroup[] getDeletedGroups() throws ContactsStorageException {
|
||||
return (LocalGroup[])queryGroups(Groups.DELETED + "!= 0", null);
|
||||
}
|
||||
|
||||
public LocalGroup[] getDirtyGroups() throws ContactsStorageException {
|
||||
return (LocalGroup[])queryGroups(Groups.DIRTY + "!= 0", null);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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 ContactsStorageException on contact provider errors
|
||||
*/
|
||||
public long findOrCreateGroup(@NonNull String title) throws ContactsStorageException {
|
||||
try {
|
||||
@Cleanup Cursor cursor = provider.query(syncAdapterURI(Groups.CONTENT_URI),
|
||||
new String[] { Groups._ID },
|
||||
Groups.TITLE + "=?", new String[] { title },
|
||||
null);
|
||||
if (cursor != null && cursor.moveToNext())
|
||||
return cursor.getLong(0);
|
||||
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(Groups.TITLE, title);
|
||||
Uri uri = provider.insert(syncAdapterURI(Groups.CONTENT_URI), values);
|
||||
return ContentUris.parseId(uri);
|
||||
} catch(RemoteException e) {
|
||||
throw new ContactsStorageException("Couldn't find local contact group", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void removeEmptyGroups() throws ContactsStorageException {
|
||||
// find groups without members
|
||||
/** should be done using {@link Groups.SUMMARY_COUNT}, but it's not implemented in Android yet */
|
||||
for (LocalGroup group : (LocalGroup[])queryGroups(null, null))
|
||||
if (group.getMembers().length == 0)
|
||||
group.delete();
|
||||
}
|
||||
|
||||
public void removeGroups() throws ContactsStorageException {
|
||||
try {
|
||||
provider.delete(syncAdapterURI(Groups.CONTENT_URI), null, null);
|
||||
} catch(RemoteException e) {
|
||||
throw new ContactsStorageException("Couldn't remove all groups", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// SYNC STATE
|
||||
|
||||
@SuppressWarnings("Recycle")
|
||||
protected void readSyncState() throws ContactsStorageException {
|
||||
@Cleanup("recycle") Parcel parcel = Parcel.obtain();
|
||||
byte[] raw = getSyncState();
|
||||
syncState.clear();
|
||||
if (raw != null) {
|
||||
parcel.unmarshall(raw, 0, raw.length);
|
||||
parcel.setDataPosition(0);
|
||||
syncState.putAll(parcel.readBundle());
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("Recycle")
|
||||
protected void writeSyncState() throws ContactsStorageException {
|
||||
@Cleanup("recycle") Parcel parcel = Parcel.obtain();
|
||||
parcel.writeBundle(syncState);
|
||||
setSyncState(parcel.marshall());
|
||||
}
|
||||
|
||||
public String getURL() throws ContactsStorageException {
|
||||
synchronized (syncState) {
|
||||
readSyncState();
|
||||
return syncState.getString(SYNC_STATE_URL);
|
||||
}
|
||||
}
|
||||
|
||||
public void setURL(String url) throws ContactsStorageException {
|
||||
synchronized (syncState) {
|
||||
readSyncState();
|
||||
syncState.putString(SYNC_STATE_URL, url);
|
||||
writeSyncState();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCTag() throws ContactsStorageException {
|
||||
synchronized (syncState) {
|
||||
readSyncState();
|
||||
return syncState.getString(SYNC_STATE_CTAG);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setCTag(String cTag) throws ContactsStorageException {
|
||||
synchronized (syncState) {
|
||||
readSyncState();
|
||||
syncState.putString(SYNC_STATE_CTAG, cTag);
|
||||
writeSyncState();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.*
|
||||
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.DavUtils
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.vcard4android.*
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.*
|
||||
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.
|
||||
*/
|
||||
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"
|
||||
|
||||
fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, info: CollectionInfo): LocalAddressBook {
|
||||
val accountManager = AccountManager.get(context)
|
||||
|
||||
val account = Account(accountName(mainAccount, info), context.getString(R.string.account_type_address_book))
|
||||
if (!accountManager.addAccountExplicitly(account, null, initialUserData(mainAccount, info.url.toString())))
|
||||
throw IllegalStateException("Couldn't create address book account")
|
||||
|
||||
val addressBook = LocalAddressBook(context, account, provider)
|
||||
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
|
||||
|
||||
// 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 = !info.privWriteContent || info.forceReadOnly
|
||||
|
||||
return addressBook
|
||||
}
|
||||
|
||||
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 { mainAccount == null || it.mainAccount == mainAccount }
|
||||
.toList()
|
||||
|
||||
fun accountName(mainAccount: Account, info: CollectionInfo): 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
|
||||
}
|
||||
|
||||
fun mainAccount(context: Context, account: Account): Account =
|
||||
if (account.type == context.getString(R.string.account_type_address_book)) {
|
||||
val manager = AccountManager.get(context)
|
||||
Account(
|
||||
manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME),
|
||||
manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE)
|
||||
)
|
||||
} else
|
||||
account
|
||||
|
||||
}
|
||||
|
||||
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 this option is disabled, [findDirty] will find only dirty [LocalContact]s,
|
||||
* but if it is enabled, [findDirty] will find dirty [LocalContact]s and [LocalGroup]s.
|
||||
*/
|
||||
var includeGroups = true
|
||||
|
||||
private var _mainAccount: Account? = null
|
||||
var mainAccount: Account
|
||||
get() {
|
||||
_mainAccount?.let { return it }
|
||||
|
||||
AccountManager.get(context).let { accountManager ->
|
||||
val name = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME)
|
||||
val type = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE)
|
||||
if (name != null && type != null)
|
||||
return Account(name, type)
|
||||
else
|
||||
throw IllegalStateException("Address book doesn't exist anymore")
|
||||
}
|
||||
}
|
||||
set(newMainAccount) {
|
||||
AccountManager.get(context).let { accountManager ->
|
||||
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_NAME, newMainAccount.name)
|
||||
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE, newMainAccount.type)
|
||||
}
|
||||
|
||||
_mainAccount = newMainAccount
|
||||
}
|
||||
|
||||
var url: String
|
||||
get() = AccountManager.get(context).getUserData(account, USER_DATA_URL)
|
||||
?: throw IllegalStateException("Address book has no URL")
|
||||
set(url) = AccountManager.get(context).setUserData(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).setUserData(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, "${Groups.DIRTY}=0", null)
|
||||
}
|
||||
|
||||
return number
|
||||
}
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int): Int {
|
||||
var number = provider!!.delete(rawContactsSyncUri(),
|
||||
"${RawContacts.DIRTY}=0 AND ${LocalContact.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
|
||||
|
||||
if (includeGroups)
|
||||
number += provider.delete(groupsSyncUri(),
|
||||
"${Groups.DIRTY}=0 AND ${LocalGroup.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
|
||||
|
||||
return number
|
||||
}
|
||||
|
||||
fun update(info: CollectionInfo) {
|
||||
val newAccountName = accountName(mainAccount, info)
|
||||
|
||||
if (account.name != newAccountName && Build.VERSION.SDK_INT >= 21) {
|
||||
// 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 = !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)
|
||||
}
|
||||
|
||||
// make sure it will still be synchronized when contacts are updated
|
||||
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
|
||||
}
|
||||
|
||||
fun delete() {
|
||||
val accountManager = AccountManager.get(context)
|
||||
@Suppress("DEPRECATION")
|
||||
if (Build.VERSION.SDK_INT >= 22)
|
||||
accountManager.removeAccount(account, null, null, null)
|
||||
else
|
||||
accountManager.removeAccount(account, null, null)
|
||||
}
|
||||
|
||||
|
||||
/* 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}!=0", null)
|
||||
fun findDeletedGroups() = queryGroups("${Groups.DELETED}!=0", 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}!=0", null)
|
||||
fun findDirtyGroups() = queryGroups("${Groups.DIRTY}!=0", 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.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
throw IllegalStateException("verifyDirty() should not be called on Android != 7")
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fun getByGroupMembership(groupID: Long): List<LocalContact> {
|
||||
val ids = HashSet<Long>()
|
||||
provider!!.query(syncAdapterURI(ContactsContract.Data.CONTENT_URI),
|
||||
arrayOf(RawContacts.Data.RAW_CONTACT_ID),
|
||||
"(${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?) OR (${CachedGroupMembership.MIMETYPE}=? AND ${CachedGroupMembership.GROUP_ID}=?)",
|
||||
arrayOf(GroupMembership.CONTENT_ITEM_TYPE, groupID.toString(), CachedGroupMembership.CONTENT_ITEM_TYPE, groupID.toString()),
|
||||
null)?.use { cursor ->
|
||||
while (cursor.moveToNext())
|
||||
ids += cursor.getLong(0)
|
||||
}
|
||||
|
||||
return ids.map { findContactByID(it) }
|
||||
}
|
||||
|
||||
|
||||
/* 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)
|
||||
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,266 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.annotation.TargetApi;
|
||||
import android.content.ContentProviderClient;
|
||||
import android.content.ContentProviderOperation;
|
||||
import android.content.ContentUris;
|
||||
import android.content.ContentValues;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.RemoteException;
|
||||
import android.provider.CalendarContract;
|
||||
import android.provider.CalendarContract.Calendars;
|
||||
import android.provider.CalendarContract.Events;
|
||||
import android.provider.CalendarContract.Reminders;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import net.fortuna.ical4j.model.component.VTimeZone;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
import at.bitfire.davdroid.App;
|
||||
import at.bitfire.davdroid.DavUtils;
|
||||
import at.bitfire.davdroid.model.CollectionInfo;
|
||||
import at.bitfire.ical4android.AndroidCalendar;
|
||||
import at.bitfire.ical4android.AndroidCalendarFactory;
|
||||
import at.bitfire.ical4android.BatchOperation;
|
||||
import at.bitfire.ical4android.CalendarStorageException;
|
||||
import at.bitfire.ical4android.DateUtils;
|
||||
import at.bitfire.vcard4android.ContactsStorageException;
|
||||
import lombok.Cleanup;
|
||||
|
||||
public class LocalCalendar extends AndroidCalendar implements LocalCollection {
|
||||
|
||||
public static final int defaultColor = 0xFF8bc34a; // light green 500
|
||||
|
||||
public static final String COLUMN_CTAG = Calendars.CAL_SYNC1;
|
||||
|
||||
protected static final int
|
||||
DIRTY_INCREASE_SEQUENCE = 1,
|
||||
DIRTY_DONT_INCREASE_SEQUENCE = 2;
|
||||
|
||||
static String[] BASE_INFO_COLUMNS = new String[] {
|
||||
Events._ID,
|
||||
Events._SYNC_ID,
|
||||
LocalEvent.COLUMN_ETAG
|
||||
};
|
||||
|
||||
@Override
|
||||
protected String[] eventBaseInfoColumns() {
|
||||
return BASE_INFO_COLUMNS;
|
||||
}
|
||||
|
||||
|
||||
protected LocalCalendar(Account account, ContentProviderClient provider, long id) {
|
||||
super(account, provider, LocalEvent.Factory.INSTANCE, id);
|
||||
}
|
||||
|
||||
public static Uri create(@NonNull Account account, @NonNull ContentProviderClient provider, @NonNull CollectionInfo info) throws CalendarStorageException {
|
||||
ContentValues 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);
|
||||
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);
|
||||
}
|
||||
|
||||
public void update(CollectionInfo info, boolean updateColor) throws CalendarStorageException {
|
||||
update(valuesFromCollectionInfo(info, updateColor));
|
||||
}
|
||||
|
||||
@TargetApi(15)
|
||||
private static ContentValues valuesFromCollectionInfo(CollectionInfo info, boolean withColor) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(Calendars.NAME, info.url);
|
||||
values.put(Calendars.CALENDAR_DISPLAY_NAME, !TextUtils.isEmpty(info.displayName) ? info.displayName : DavUtils.lastSegmentOfUrl(info.url));
|
||||
|
||||
if (withColor)
|
||||
values.put(Calendars.CALENDAR_COLOR, info.color != null ? info.color : defaultColor);
|
||||
|
||||
if (info.readOnly)
|
||||
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ);
|
||||
else {
|
||||
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);
|
||||
}
|
||||
|
||||
if (!TextUtils.isEmpty(info.timeZone)) {
|
||||
VTimeZone timeZone = DateUtils.parseVTimeZone(info.timeZone);
|
||||
if (timeZone != null && timeZone.getTimeZoneId() != null)
|
||||
values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(timeZone.getTimeZoneId().getValue()));
|
||||
}
|
||||
values.put(Calendars.ALLOWED_REMINDERS, Reminders.METHOD_ALERT);
|
||||
if (Build.VERSION.SDK_INT >= 15) {
|
||||
values.put(Calendars.ALLOWED_AVAILABILITY, StringUtils.join(new int[] { Reminders.AVAILABILITY_TENTATIVE, Reminders.AVAILABILITY_FREE, Reminders.AVAILABILITY_BUSY }, ","));
|
||||
values.put(Calendars.ALLOWED_ATTENDEE_TYPES, StringUtils.join(new int[] { CalendarContract.Attendees.TYPE_OPTIONAL, CalendarContract.Attendees.TYPE_REQUIRED, CalendarContract.Attendees.TYPE_RESOURCE }, ", "));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public LocalResource[] getAll() throws CalendarStorageException, ContactsStorageException {
|
||||
return (LocalEvent[])queryEvents(Events.ORIGINAL_ID + " IS NULL", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalEvent[] getDeleted() throws CalendarStorageException {
|
||||
return (LocalEvent[])queryEvents(Events.DELETED + "!=0 AND " + Events.ORIGINAL_ID + " IS NULL", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalEvent[] getWithoutFileName() throws CalendarStorageException {
|
||||
return (LocalEvent[])queryEvents(Events._SYNC_ID + " IS NULL AND " + Events.ORIGINAL_ID + " IS NULL", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalResource[] getDirty() throws CalendarStorageException, FileNotFoundException {
|
||||
List<LocalResource> dirty = new LinkedList<>();
|
||||
|
||||
// get dirty events which are not required to have an increased SEQUENCE value
|
||||
Collections.addAll(dirty, (LocalEvent[])queryEvents(Events.DIRTY + "=" + DIRTY_DONT_INCREASE_SEQUENCE + " AND " + Events.ORIGINAL_ID + " IS NULL", null));
|
||||
|
||||
// get dirty events which are required to have an increased SEQUENCE value
|
||||
for (LocalEvent event : (LocalEvent[])queryEvents(Events.DIRTY + "=" + DIRTY_INCREASE_SEQUENCE + " AND " + Events.ORIGINAL_ID + " IS NULL", null)) {
|
||||
if (event.getEvent().sequence == null) // sequence has not been assigned yet (i.e. this event was just locally created)
|
||||
event.getEvent().sequence = 0;
|
||||
else
|
||||
event.getEvent().sequence++;
|
||||
dirty.add(event);
|
||||
}
|
||||
|
||||
return dirty.toArray(new LocalResource[dirty.size()]);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("Recycle")
|
||||
public String getCTag() throws CalendarStorageException {
|
||||
try {
|
||||
@Cleanup Cursor cursor = provider.query(calendarSyncURI(), new String[] { COLUMN_CTAG }, null, null, null);
|
||||
if (cursor != null && cursor.moveToNext())
|
||||
return cursor.getString(0);
|
||||
} catch (RemoteException e) {
|
||||
throw new CalendarStorageException("Couldn't read local (last known) CTag", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setCTag(String cTag) throws CalendarStorageException, ContactsStorageException {
|
||||
try {
|
||||
ContentValues values = new ContentValues(1);
|
||||
values.put(COLUMN_CTAG, cTag);
|
||||
provider.update(calendarSyncURI(), values, null, null);
|
||||
} catch (RemoteException e) {
|
||||
throw new CalendarStorageException("Couldn't write local (last known) CTag", e);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("Recycle")
|
||||
public void processDirtyExceptions() throws CalendarStorageException {
|
||||
// process deleted exceptions
|
||||
App.log.info("Processing deleted exceptions");
|
||||
try {
|
||||
@Cleanup Cursor cursor = provider.query(
|
||||
syncAdapterURI(Events.CONTENT_URI),
|
||||
new String[] { Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE },
|
||||
Events.DELETED + "!=0 AND " + Events.ORIGINAL_ID + " IS NOT NULL", null, null);
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
App.log.fine("Found deleted exception, removing; then re-schuling original event");
|
||||
long id = cursor.getLong(0), // can't be null (by definition)
|
||||
originalID = cursor.getLong(1); // can't be null (by query)
|
||||
|
||||
// get original event's SEQUENCE
|
||||
@Cleanup Cursor cursor2 = provider.query(
|
||||
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)),
|
||||
new String[] { LocalEvent.COLUMN_SEQUENCE },
|
||||
null, null, null);
|
||||
int originalSequence = (cursor2 == null || cursor2.isNull(0)) ? 0 : cursor2.getInt(0);
|
||||
|
||||
BatchOperation batch = new BatchOperation(provider);
|
||||
// re-schedule original event and set it to DIRTY
|
||||
batch.enqueue(ContentProviderOperation.newUpdate(
|
||||
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
|
||||
.withValue(LocalEvent.COLUMN_SEQUENCE, originalSequence + 1)
|
||||
.withValue(Events.DIRTY, DIRTY_INCREASE_SEQUENCE)
|
||||
.build());
|
||||
// remove exception
|
||||
batch.enqueue(ContentProviderOperation.newDelete(
|
||||
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id))).build());
|
||||
batch.commit();
|
||||
}
|
||||
} catch (RemoteException e) {
|
||||
throw new CalendarStorageException("Couldn't process locally modified exception", e);
|
||||
}
|
||||
|
||||
// process dirty exceptions
|
||||
App.log.info("Processing dirty exceptions");
|
||||
try {
|
||||
@Cleanup Cursor cursor = provider.query(
|
||||
syncAdapterURI(Events.CONTENT_URI),
|
||||
new String[] { Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE },
|
||||
Events.DIRTY + "!=0 AND " + Events.ORIGINAL_ID + " IS NOT NULL", null, null);
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
App.log.fine("Found dirty exception, increasing SEQUENCE to re-schedule");
|
||||
long id = cursor.getLong(0), // can't be null (by definition)
|
||||
originalID = cursor.getLong(1); // can't be null (by query)
|
||||
int sequence = cursor.isNull(2) ? 0 : cursor.getInt(2);
|
||||
|
||||
BatchOperation batch = new BatchOperation(provider);
|
||||
// original event to DIRTY
|
||||
batch.enqueue(ContentProviderOperation.newUpdate(
|
||||
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
|
||||
.withValue(Events.DIRTY, DIRTY_DONT_INCREASE_SEQUENCE)
|
||||
.build());
|
||||
// increase SEQUENCE and set DIRTY to 0
|
||||
batch.enqueue(ContentProviderOperation.newUpdate(
|
||||
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)))
|
||||
.withValue(LocalEvent.COLUMN_SEQUENCE, sequence + 1)
|
||||
.withValue(Events.DIRTY, 0)
|
||||
.build());
|
||||
batch.commit();
|
||||
}
|
||||
} catch (RemoteException e) {
|
||||
throw new CalendarStorageException("Couldn't process locally modified exception", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static class Factory implements AndroidCalendarFactory {
|
||||
public static final Factory INSTANCE = new Factory();
|
||||
|
||||
@Override
|
||||
public AndroidCalendar newInstance(Account account, ContentProviderClient provider, long id) {
|
||||
return new LocalCalendar(account, provider, id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AndroidCalendar[] newArray(int size) {
|
||||
return new LocalCalendar[size];
|
||||
}
|
||||
}
|
||||
}
|
||||
225
app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.kt
Normal file
225
app/src/main/java/at/bitfire/davdroid/resource/LocalCalendar.kt
Normal file
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.*
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.DavUtils
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.AndroidCalendarFactory
|
||||
import at.bitfire.ical4android.BatchOperation
|
||||
import at.bitfire.ical4android.DateUtils
|
||||
import java.util.*
|
||||
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: CollectionInfo): 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)
|
||||
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: CollectionInfo, 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)
|
||||
}
|
||||
}
|
||||
values.put(Calendars.ALLOWED_REMINDERS, "${Reminders.METHOD_ALERT},${Reminders.METHOD_EMAIL}")
|
||||
values.put(Calendars.ALLOWED_AVAILABILITY, "${Reminders.AVAILABILITY_TENTATIVE},${Reminders.AVAILABILITY_FREE},${Reminders.AVAILABILITY_BUSY}")
|
||||
values.put(Calendars.ALLOWED_ATTENDEE_TYPES, "${CalendarContract.Attendees.TYPE_OPTIONAL},${CalendarContract.Attendees.TYPE_REQUIRED},${CalendarContract.Attendees.TYPE_RESOURCE}")
|
||||
return values
|
||||
}
|
||||
}
|
||||
|
||||
override val title: String
|
||||
get() = displayName ?: id.toString()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
fun update(info: CollectionInfo, updateColor: Boolean) =
|
||||
update(valuesFromCollectionInfo(info, updateColor))
|
||||
|
||||
|
||||
override fun findDeleted() =
|
||||
queryEvents("${Events.DELETED}!=0 AND ${Events.ORIGINAL_ID} IS NULL", null)
|
||||
|
||||
override fun findDirty(): List<LocalEvent> {
|
||||
val dirty = LinkedList<LocalEvent>()
|
||||
|
||||
// get dirty events which are required to have an increased SEQUENCE value
|
||||
for (localEvent in queryEvents("${Events.DIRTY}!=0 AND ${Events.ORIGINAL_ID} IS NULL", null)) {
|
||||
val event = localEvent.event!!
|
||||
val sequence = event.sequence
|
||||
if (event.sequence == null) // sequence has not been assigned yet (i.e. this event was just locally created)
|
||||
event.sequence = 0
|
||||
else if (localEvent.weAreOrganizer)
|
||||
event.sequence = sequence!! + 1
|
||||
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(eventsSyncURI(), values,
|
||||
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY}=0 AND ${Events.ORIGINAL_ID} IS NULL",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int) =
|
||||
provider.delete(eventsSyncURI(),
|
||||
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY}=0 AND ${Events.ORIGINAL_ID} IS NULL AND ${LocalEvent.COLUMN_FLAGS}=?",
|
||||
arrayOf(id.toString(), flags.toString()))
|
||||
|
||||
|
||||
fun processDirtyExceptions() {
|
||||
// process deleted exceptions
|
||||
Logger.log.info("Processing deleted exceptions")
|
||||
provider.query(
|
||||
syncAdapterURI(Events.CONTENT_URI),
|
||||
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
|
||||
"${Events.CALENDAR_ID}=? AND ${Events.DELETED}!=0 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(
|
||||
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)),
|
||||
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.Operation(
|
||||
ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
|
||||
.withValue(LocalEvent.COLUMN_SEQUENCE, originalSequence + 1)
|
||||
.withValue(Events.DIRTY, 1)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// completely remove deleted exception
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newDelete(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)))
|
||||
))
|
||||
batch.commit()
|
||||
}
|
||||
}
|
||||
|
||||
// process dirty exceptions
|
||||
Logger.log.info("Processing dirty exceptions")
|
||||
provider.query(
|
||||
syncAdapterURI(Events.CONTENT_URI),
|
||||
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
|
||||
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY}!=0 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.Operation (
|
||||
ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
|
||||
.withValue(Events.DIRTY, 1)
|
||||
))
|
||||
// increase SEQUENCE and set DIRTY to 0
|
||||
batch.enqueue(BatchOperation.Operation (
|
||||
ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)))
|
||||
.withValue(LocalEvent.COLUMN_SEQUENCE, sequence + 1)
|
||||
.withValue(Events.DIRTY, 0)
|
||||
))
|
||||
batch.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
object Factory: AndroidCalendarFactory<LocalCalendar> {
|
||||
|
||||
override fun newInstance(account: Account, provider: ContentProviderClient, id: Long) =
|
||||
LocalCalendar(account, provider, id)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
|
||||
import at.bitfire.ical4android.CalendarStorageException;
|
||||
import at.bitfire.vcard4android.ContactsStorageException;
|
||||
|
||||
public interface LocalCollection {
|
||||
|
||||
LocalResource[] getDeleted() throws CalendarStorageException, ContactsStorageException;
|
||||
LocalResource[] getWithoutFileName() throws CalendarStorageException, ContactsStorageException;
|
||||
LocalResource[] getDirty() throws CalendarStorageException, ContactsStorageException, FileNotFoundException;
|
||||
|
||||
LocalResource[] getAll() throws CalendarStorageException, ContactsStorageException;
|
||||
|
||||
String getCTag() throws CalendarStorageException, ContactsStorageException;
|
||||
void setCTag(String cTag) throws CalendarStorageException, ContactsStorageException;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.provider.CalendarContract.Events
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
|
||||
interface LocalCollection<out T: LocalResource<*>> {
|
||||
|
||||
/** collection title (used for user notifications etc.) **/
|
||||
val title: String
|
||||
|
||||
var lastSyncState: SyncState?
|
||||
|
||||
fun findDeleted(): List<T>
|
||||
fun findDirty(): List<T>
|
||||
|
||||
fun findByName(name: String): T?
|
||||
|
||||
|
||||
/**
|
||||
* Sets the [LocalEvent.COLUMN_FLAGS] value for entries which are not dirty ([Events.DIRTY] is 0)
|
||||
* and have an [Events.ORIGINAL_ID] of null.
|
||||
*
|
||||
* @param flags value of flags to set (for instance, [LocalResource.FLAG_REMOTELY_PRESENT]])
|
||||
*
|
||||
* @return number of marked entries
|
||||
*/
|
||||
fun markNotDirty(flags: Int): Int
|
||||
|
||||
/**
|
||||
* Removes entries which are not dirty ([Events.DIRTY] is 0 and an [Events.ORIGINAL_ID] is null) with
|
||||
* a given flag combination.
|
||||
*
|
||||
* @param flags exact flags value to remove entries with (for instance, if this is [LocalResource.FLAG_REMOTELY_PRESENT]],
|
||||
* all entries with exactly this flag will be removed)
|
||||
*
|
||||
* @return number of removed entries
|
||||
*/
|
||||
fun removeNotDirtyMarked(flags: Int): Int
|
||||
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource;
|
||||
|
||||
import android.content.ContentProviderOperation;
|
||||
import android.content.ContentValues;
|
||||
import android.os.RemoteException;
|
||||
import android.provider.ContactsContract;
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
|
||||
import android.provider.ContactsContract.RawContacts.Data;
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import at.bitfire.davdroid.BuildConfig;
|
||||
import at.bitfire.davdroid.model.UnknownProperties;
|
||||
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 at.bitfire.vcard4android.ContactsStorageException;
|
||||
import ezvcard.Ezvcard;
|
||||
|
||||
public class LocalContact extends AndroidContact implements LocalResource {
|
||||
static {
|
||||
Contact.productID = "+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " vcard4android ez-vcard/" + Ezvcard.VERSION;
|
||||
}
|
||||
|
||||
protected final Set<Long>
|
||||
cachedGroupMemberships = new HashSet<>(),
|
||||
groupMemberships = new HashSet<>();
|
||||
|
||||
|
||||
protected LocalContact(AndroidAddressBook addressBook, long id, String fileName, String eTag) {
|
||||
super(addressBook, id, fileName, eTag);
|
||||
}
|
||||
|
||||
public LocalContact(AndroidAddressBook addressBook, Contact contact, String fileName, String eTag) {
|
||||
super(addressBook, contact, fileName, eTag);
|
||||
}
|
||||
|
||||
public void clearDirty(String eTag) throws ContactsStorageException {
|
||||
try {
|
||||
ContentValues values = new ContentValues(1);
|
||||
values.put(ContactsContract.RawContacts.DIRTY, 0);
|
||||
values.put(COLUMN_ETAG, eTag);
|
||||
addressBook.provider.update(rawContactSyncURI(), values, null, null);
|
||||
|
||||
this.eTag = eTag;
|
||||
} catch (RemoteException e) {
|
||||
throw new ContactsStorageException("Couldn't clear dirty flag", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void updateFileNameAndUID(String uid) throws ContactsStorageException {
|
||||
try {
|
||||
String newFileName = uid + ".vcf";
|
||||
|
||||
ContentValues values = new ContentValues(2);
|
||||
values.put(COLUMN_FILENAME, newFileName);
|
||||
values.put(COLUMN_UID, uid);
|
||||
addressBook.provider.update(rawContactSyncURI(), values, null, null);
|
||||
|
||||
fileName = newFileName;
|
||||
} catch (RemoteException e) {
|
||||
throw new ContactsStorageException("Couldn't update UID", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
protected void populateData(String mimeType, ContentValues row) {
|
||||
switch (mimeType) {
|
||||
case CachedGroupMembership.CONTENT_ITEM_TYPE:
|
||||
cachedGroupMemberships.add(row.getAsLong(CachedGroupMembership.GROUP_ID));
|
||||
break;
|
||||
case GroupMembership.CONTENT_ITEM_TYPE:
|
||||
groupMemberships.add(row.getAsLong(GroupMembership.GROUP_ROW_ID));
|
||||
break;
|
||||
case UnknownProperties.CONTENT_ITEM_TYPE:
|
||||
contact.unknownProperties = row.getAsString(UnknownProperties.UNKNOWN_PROPERTIES);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void insertDataRows(BatchOperation batch) throws ContactsStorageException {
|
||||
super.insertDataRows(batch);
|
||||
|
||||
if (contact.unknownProperties != null) {
|
||||
ContentProviderOperation.Builder builder = ContentProviderOperation.newInsert(dataSyncURI());
|
||||
if (id == null)
|
||||
builder.withValueBackReference(UnknownProperties.RAW_CONTACT_ID, 0);
|
||||
else
|
||||
builder.withValue(UnknownProperties.RAW_CONTACT_ID, id);
|
||||
builder .withValue(UnknownProperties.MIMETYPE, UnknownProperties.CONTENT_ITEM_TYPE)
|
||||
.withValue(UnknownProperties.UNKNOWN_PROPERTIES, contact.unknownProperties);
|
||||
batch.enqueue(builder.build());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
public void addToGroup(BatchOperation batch, long groupID) {
|
||||
assertID();
|
||||
batch.enqueue(ContentProviderOperation
|
||||
.newInsert(dataSyncURI())
|
||||
.withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
|
||||
.withValue(GroupMembership.RAW_CONTACT_ID, id)
|
||||
.withValue(GroupMembership.GROUP_ROW_ID, groupID)
|
||||
.build()
|
||||
);
|
||||
|
||||
batch.enqueue(ContentProviderOperation
|
||||
.newInsert(dataSyncURI())
|
||||
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
.withValue(CachedGroupMembership.RAW_CONTACT_ID, id)
|
||||
.withValue(CachedGroupMembership.GROUP_ID, groupID)
|
||||
.withYieldAllowed(true)
|
||||
.build()
|
||||
);
|
||||
}
|
||||
|
||||
public void removeGroupMemberships(BatchOperation batch) {
|
||||
assertID();
|
||||
batch.enqueue(ContentProviderOperation
|
||||
.newDelete(dataSyncURI())
|
||||
.withSelection(
|
||||
Data.RAW_CONTACT_ID + "=? AND " + Data.MIMETYPE + " IN (?,?)",
|
||||
new String[] { String.valueOf(id), GroupMembership.CONTENT_ITEM_TYPE, CachedGroupMembership.CONTENT_ITEM_TYPE }
|
||||
)
|
||||
.withYieldAllowed(true)
|
||||
.build()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the IDs of all groups the contact was member of (cached memberships).
|
||||
* Cached memberships are kept in sync with memberships by DAVdroid 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 ContactsStorageException on contact provider errors
|
||||
* @throws FileNotFoundException if the current contact can't be found
|
||||
*/
|
||||
@NonNull
|
||||
public Set<Long> getCachedGroupMemberships() throws ContactsStorageException, FileNotFoundException {
|
||||
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 ContactsStorageException on contact provider errors
|
||||
* @throws FileNotFoundException if the current contact can't be found
|
||||
*/
|
||||
@NonNull
|
||||
public Set<Long> getGroupMemberships() throws ContactsStorageException, FileNotFoundException {
|
||||
getContact();
|
||||
return groupMemberships;
|
||||
}
|
||||
|
||||
|
||||
// factory
|
||||
|
||||
static class Factory extends AndroidContactFactory {
|
||||
static final Factory INSTANCE = new Factory();
|
||||
|
||||
@Override
|
||||
public LocalContact newInstance(AndroidAddressBook addressBook, long id, String fileName, String eTag) {
|
||||
return new LocalContact(addressBook, id, fileName, eTag);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalContact newInstance(AndroidAddressBook addressBook, Contact contact, String fileName, String eTag) {
|
||||
return new LocalContact(addressBook, contact, fileName, eTag);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalContact[] newArray(int size) {
|
||||
return new LocalContact[size];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
253
app/src/main/java/at/bitfire/davdroid/resource/LocalContact.kt
Normal file
253
app/src/main/java/at/bitfire/davdroid/resource/LocalContact.kt
Normal file
@@ -0,0 +1,253 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentProviderOperation
|
||||
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.model.UnknownProperties
|
||||
import at.bitfire.vcard4android.*
|
||||
import ezvcard.Ezvcard
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.*
|
||||
|
||||
class LocalContact: AndroidContact, LocalAddress {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private val cachedGroupMemberships = HashSet<Long>()
|
||||
private val groupMemberships = HashSet<Long>()
|
||||
|
||||
override var flags: Int = 0
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<LocalContact,*>, values: ContentValues)
|
||||
: super(addressBook, values) {
|
||||
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
|
||||
}
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<LocalContact,*>, contact: Contact, fileName: String?, eTag: String?, flags: Int)
|
||||
: super(addressBook, contact, fileName, eTag) {
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
|
||||
override fun assignNameAndUID() {
|
||||
val uid = UUID.randomUUID().toString()
|
||||
val newFileName = "$uid.vcf"
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(COLUMN_FILENAME, newFileName)
|
||||
values.put(COLUMN_UID, uid)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
fileName = newFileName
|
||||
}
|
||||
|
||||
override fun clearDirty(eTag: String?) {
|
||||
val values = ContentValues(3)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(ContactsContract.RawContacts.DIRTY, 0)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && 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)
|
||||
|
||||
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(LocalContact.COLUMN_FLAGS, flags)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
|
||||
override fun populateData(mimeType: String, row: ContentValues) {
|
||||
when (mimeType) {
|
||||
CachedGroupMembership.CONTENT_ITEM_TYPE ->
|
||||
cachedGroupMemberships += row.getAsLong(CachedGroupMembership.GROUP_ID)
|
||||
GroupMembership.CONTENT_ITEM_TYPE ->
|
||||
groupMemberships += row.getAsLong(GroupMembership.GROUP_ROW_ID)
|
||||
UnknownProperties.CONTENT_ITEM_TYPE ->
|
||||
contact!!.unknownProperties = row.getAsString(UnknownProperties.UNKNOWN_PROPERTIES)
|
||||
}
|
||||
}
|
||||
|
||||
override fun insertDataRows(batch: BatchOperation) {
|
||||
super.insertDataRows(batch)
|
||||
|
||||
contact!!.unknownProperties?.let { unknownProperties ->
|
||||
val op: BatchOperation.Operation
|
||||
val builder = ContentProviderOperation.newInsert(dataSyncURI())
|
||||
if (id == null)
|
||||
op = BatchOperation.Operation(builder, UnknownProperties.RAW_CONTACT_ID, 0)
|
||||
else {
|
||||
op = BatchOperation.Operation(builder)
|
||||
builder.withValue(UnknownProperties.RAW_CONTACT_ID, id)
|
||||
}
|
||||
builder .withValue(UnknownProperties.MIMETYPE, UnknownProperties.CONTENT_ITEM_TYPE)
|
||||
.withValue(UnknownProperties.UNKNOWN_PROPERTIES, unknownProperties)
|
||||
batch.enqueue(op)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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.N || 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 = contact!!.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.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
throw IllegalStateException("updateHashCode() should not be called on Android != 7")
|
||||
|
||||
val values = ContentValues(1)
|
||||
val hashCode = dataHashCode()
|
||||
Logger.log.fine("Storing contact hash = $hashCode")
|
||||
values.put(COLUMN_HASHCODE, hashCode)
|
||||
|
||||
if (batch == null)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
else {
|
||||
val builder = ContentProviderOperation
|
||||
.newUpdate(rawContactSyncURI())
|
||||
.withValues(values)
|
||||
batch.enqueue(BatchOperation.Operation(builder))
|
||||
}
|
||||
}
|
||||
|
||||
fun getLastHashCode(): Int {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || 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.Operation(
|
||||
ContentProviderOperation.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.Operation(
|
||||
ContentProviderOperation.newInsert(dataSyncURI())
|
||||
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
.withValue(CachedGroupMembership.RAW_CONTACT_ID, id)
|
||||
.withValue(CachedGroupMembership.GROUP_ID, groupID)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
cachedGroupMemberships += groupID
|
||||
}
|
||||
|
||||
fun removeGroupMemberships(batch: BatchOperation) {
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newDelete(dataSyncURI())
|
||||
.withSelection(
|
||||
Data.RAW_CONTACT_ID + "=? AND " + Data.MIMETYPE + " IN (?,?)",
|
||||
arrayOf(id.toString(), GroupMembership.CONTENT_ITEM_TYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
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> {
|
||||
contact
|
||||
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> {
|
||||
contact
|
||||
return groupMemberships
|
||||
}
|
||||
|
||||
|
||||
// data rows
|
||||
override fun buildContact(builder: ContentProviderOperation.Builder, 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, values)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource;
|
||||
|
||||
import android.annotation.TargetApi;
|
||||
import android.content.ContentProviderOperation;
|
||||
import android.content.ContentValues;
|
||||
import android.os.Build;
|
||||
import android.os.RemoteException;
|
||||
import android.provider.CalendarContract;
|
||||
import android.provider.CalendarContract.Events;
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
import net.fortuna.ical4j.model.property.ProdId;
|
||||
|
||||
import at.bitfire.davdroid.BuildConfig;
|
||||
import at.bitfire.ical4android.AndroidCalendar;
|
||||
import at.bitfire.ical4android.AndroidEvent;
|
||||
import at.bitfire.ical4android.AndroidEventFactory;
|
||||
import at.bitfire.ical4android.CalendarStorageException;
|
||||
import at.bitfire.ical4android.Event;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@TargetApi(17)
|
||||
public class LocalEvent extends AndroidEvent implements LocalResource {
|
||||
static {
|
||||
Event.prodId = new ProdId("+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ical4android ical4j/2.x");
|
||||
}
|
||||
|
||||
static final String COLUMN_ETAG = CalendarContract.Events.SYNC_DATA1,
|
||||
COLUMN_UID = Build.VERSION.SDK_INT >= 17 ? Events.UID_2445 : Events.SYNC_DATA2,
|
||||
COLUMN_SEQUENCE = CalendarContract.Events.SYNC_DATA3;
|
||||
|
||||
@Getter protected String fileName;
|
||||
@Getter @Setter protected String eTag;
|
||||
|
||||
public LocalEvent(@NonNull AndroidCalendar calendar, Event event, String fileName, String eTag) {
|
||||
super(calendar, event);
|
||||
this.fileName = fileName;
|
||||
this.eTag = eTag;
|
||||
}
|
||||
|
||||
protected LocalEvent(@NonNull AndroidCalendar calendar, long id, ContentValues baseInfo) {
|
||||
super(calendar, id, baseInfo);
|
||||
if (baseInfo != null) {
|
||||
fileName = baseInfo.getAsString(Events._SYNC_ID);
|
||||
eTag = baseInfo.getAsString(COLUMN_ETAG);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* process LocalEvent-specific fields */
|
||||
|
||||
@Override
|
||||
protected void populateEvent(ContentValues values) {
|
||||
super.populateEvent(values);
|
||||
fileName = values.getAsString(Events._SYNC_ID);
|
||||
eTag = values.getAsString(COLUMN_ETAG);
|
||||
event.uid = values.getAsString(COLUMN_UID);
|
||||
|
||||
event.sequence = values.getAsInteger(COLUMN_SEQUENCE);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void buildEvent(Event recurrence, ContentProviderOperation.Builder builder) {
|
||||
super.buildEvent(recurrence, builder);
|
||||
|
||||
boolean buildException = recurrence != null;
|
||||
Event eventToBuild = buildException ? recurrence : event;
|
||||
|
||||
builder .withValue(COLUMN_UID, event.uid)
|
||||
.withValue(COLUMN_SEQUENCE, eventToBuild.sequence)
|
||||
.withValue(CalendarContract.Events.DIRTY, 0)
|
||||
.withValue(CalendarContract.Events.DELETED, 0);
|
||||
|
||||
if (buildException)
|
||||
builder.withValue(Events.ORIGINAL_SYNC_ID, fileName);
|
||||
else
|
||||
builder .withValue(Events._SYNC_ID, fileName)
|
||||
.withValue(COLUMN_ETAG, eTag);
|
||||
}
|
||||
|
||||
|
||||
/* custom queries */
|
||||
|
||||
public void updateFileNameAndUID(String uid) throws CalendarStorageException {
|
||||
try {
|
||||
String newFileName = uid + ".ics";
|
||||
|
||||
ContentValues values = new ContentValues(2);
|
||||
values.put(Events._SYNC_ID, newFileName);
|
||||
values.put(COLUMN_UID, uid);
|
||||
calendar.provider.update(eventSyncURI(), values, null, null);
|
||||
|
||||
fileName = newFileName;
|
||||
if (event != null)
|
||||
event.uid = uid;
|
||||
|
||||
} catch (RemoteException e) {
|
||||
throw new CalendarStorageException("Couldn't update UID", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearDirty(String eTag) throws CalendarStorageException {
|
||||
try {
|
||||
ContentValues values = new ContentValues(2);
|
||||
values.put(CalendarContract.Events.DIRTY, 0);
|
||||
values.put(COLUMN_ETAG, eTag);
|
||||
if (event != null)
|
||||
values.put(COLUMN_SEQUENCE, event.sequence);
|
||||
calendar.provider.update(eventSyncURI(), values, null, null);
|
||||
|
||||
this.eTag = eTag;
|
||||
} catch (RemoteException e) {
|
||||
throw new CalendarStorageException("Couldn't update UID", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static class Factory implements AndroidEventFactory {
|
||||
static final Factory INSTANCE = new Factory();
|
||||
|
||||
@Override
|
||||
public AndroidEvent newInstance(AndroidCalendar calendar, long id, ContentValues baseInfo) {
|
||||
return new LocalEvent(calendar, id, baseInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AndroidEvent newInstance(AndroidCalendar calendar, Event event) {
|
||||
return new LocalEvent(calendar, event, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AndroidEvent[] newArray(int size) {
|
||||
return new LocalEvent[size];
|
||||
}
|
||||
}
|
||||
}
|
||||
131
app/src/main/java/at/bitfire/davdroid/resource/LocalEvent.kt
Normal file
131
app/src/main/java/at/bitfire/davdroid/resource/LocalEvent.kt
Normal file
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentValues
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.Events
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.ical4android.*
|
||||
import net.fortuna.ical4j.model.property.ProdId
|
||||
import java.util.*
|
||||
|
||||
class LocalEvent: AndroidEvent, LocalResource<Event> {
|
||||
|
||||
companion object {
|
||||
init {
|
||||
ICalendar.prodId = ProdId("+//IDN bitfire.at//${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ical4j/" + Constants.ical4jVersion)
|
||||
}
|
||||
|
||||
const val COLUMN_ETAG = CalendarContract.Events.SYNC_DATA1
|
||||
const val COLUMN_FLAGS = CalendarContract.Events.SYNC_DATA2
|
||||
const val COLUMN_SEQUENCE = CalendarContract.Events.SYNC_DATA3
|
||||
}
|
||||
|
||||
override var fileName: String? = null
|
||||
private set
|
||||
|
||||
override var eTag: String? = null
|
||||
|
||||
override var flags: Int = 0
|
||||
private set
|
||||
|
||||
var weAreOrganizer = true
|
||||
|
||||
|
||||
constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?, flags: Int): super(calendar, event) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
private constructor(calendar: AndroidCalendar<*>, values: ContentValues): super(calendar, values) {
|
||||
fileName = values.getAsString(Events._SYNC_ID)
|
||||
eTag = values.getAsString(COLUMN_ETAG)
|
||||
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
|
||||
}
|
||||
|
||||
override fun populateEvent(row: ContentValues) {
|
||||
super.populateEvent(row)
|
||||
val event = requireNotNull(event)
|
||||
|
||||
event.uid = row.getAsString(Events.UID_2445)
|
||||
event.sequence = row.getAsInteger(COLUMN_SEQUENCE)
|
||||
|
||||
val isOrganizer = row.getAsInteger(Events.IS_ORGANIZER)
|
||||
weAreOrganizer = isOrganizer != null && isOrganizer != 0
|
||||
}
|
||||
|
||||
override fun buildEvent(recurrence: Event?, builder: ContentProviderOperation.Builder) {
|
||||
super.buildEvent(recurrence, builder)
|
||||
val event = requireNotNull(event)
|
||||
|
||||
val buildException = recurrence != null
|
||||
val eventToBuild = recurrence ?: event
|
||||
|
||||
builder .withValue(Events.UID_2445, event.uid)
|
||||
.withValue(COLUMN_SEQUENCE, eventToBuild.sequence)
|
||||
.withValue(CalendarContract.Events.DIRTY, 0)
|
||||
.withValue(CalendarContract.Events.DELETED, 0)
|
||||
.withValue(LocalEvent.COLUMN_FLAGS, flags)
|
||||
|
||||
if (buildException)
|
||||
builder .withValue(Events.ORIGINAL_SYNC_ID, fileName)
|
||||
else
|
||||
builder .withValue(Events._SYNC_ID, fileName)
|
||||
.withValue(COLUMN_ETAG, eTag)
|
||||
}
|
||||
|
||||
|
||||
override fun assignNameAndUID() {
|
||||
var uid: String? = null
|
||||
calendar.provider.query(eventSyncURI(), arrayOf(Events.UID_2445), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
uid = cursor.getString(0)
|
||||
}
|
||||
if (uid == null)
|
||||
uid = UUID.randomUUID().toString()
|
||||
|
||||
val newFileName = "$uid.ics"
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(Events._SYNC_ID, newFileName)
|
||||
values.put(Events.UID_2445, uid)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
fileName = newFileName
|
||||
event!!.uid = uid
|
||||
}
|
||||
|
||||
override fun clearDirty(eTag: String?) {
|
||||
val values = ContentValues(2)
|
||||
values.put(CalendarContract.Events.DIRTY, 0)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(COLUMN_SEQUENCE, event!!.sequence)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
this.eTag = eTag
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
|
||||
object Factory: AndroidEventFactory<LocalEvent> {
|
||||
override fun fromProvider(calendar: AndroidCalendar<*>, values: ContentValues) =
|
||||
LocalEvent(calendar, values)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource;
|
||||
|
||||
import android.content.ContentProviderOperation;
|
||||
import android.content.ContentUris;
|
||||
import android.content.ContentValues;
|
||||
import android.database.Cursor;
|
||||
import android.os.Parcel;
|
||||
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.provider.ContactsContract.RawContacts.Data;
|
||||
import android.provider.ContactsContract.RawContactsEntity;
|
||||
|
||||
import org.apache.commons.lang3.ArrayUtils;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import at.bitfire.dav4android.Constants;
|
||||
import at.bitfire.vcard4android.AndroidAddressBook;
|
||||
import at.bitfire.vcard4android.AndroidGroup;
|
||||
import at.bitfire.vcard4android.AndroidGroupFactory;
|
||||
import at.bitfire.vcard4android.BatchOperation;
|
||||
import at.bitfire.vcard4android.CachedGroupMembership;
|
||||
import at.bitfire.vcard4android.Contact;
|
||||
import at.bitfire.vcard4android.ContactsStorageException;
|
||||
import lombok.Cleanup;
|
||||
import lombok.ToString;
|
||||
|
||||
@ToString(callSuper=true)
|
||||
public class LocalGroup extends AndroidGroup implements LocalResource {
|
||||
/** marshalled list of member UIDs, as sent by server */
|
||||
public static final String COLUMN_PENDING_MEMBERS = Groups.SYNC3;
|
||||
|
||||
public LocalGroup(AndroidAddressBook addressBook, long id, String fileName, String eTag) {
|
||||
super(addressBook, id, fileName, eTag);
|
||||
}
|
||||
|
||||
public LocalGroup(AndroidAddressBook addressBook, Contact contact, String fileName, String eTag) {
|
||||
super(addressBook, contact, fileName, eTag);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void clearDirty(String eTag) throws ContactsStorageException {
|
||||
assertID();
|
||||
|
||||
ContentValues values = new ContentValues(2);
|
||||
values.put(Groups.DIRTY, 0);
|
||||
values.put(COLUMN_ETAG, this.eTag = eTag);
|
||||
update(values);
|
||||
|
||||
// update cached group memberships
|
||||
BatchOperation batch = new BatchOperation(addressBook.provider);
|
||||
|
||||
// delete cached group memberships
|
||||
batch.enqueue(ContentProviderOperation
|
||||
.newDelete(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
|
||||
.withSelection(
|
||||
CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?",
|
||||
new String[] { CachedGroupMembership.CONTENT_ITEM_TYPE, String.valueOf(id) }
|
||||
).build()
|
||||
);
|
||||
|
||||
// insert updated cached group memberships
|
||||
for (long member : getMembers())
|
||||
batch.enqueue(ContentProviderOperation
|
||||
.newInsert(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
|
||||
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
.withValue(CachedGroupMembership.RAW_CONTACT_ID, member)
|
||||
.withValue(CachedGroupMembership.GROUP_ID, id)
|
||||
.withYieldAllowed(true)
|
||||
.build()
|
||||
);
|
||||
|
||||
batch.commit();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateFileNameAndUID(String uid) throws ContactsStorageException {
|
||||
String newFileName = uid + ".vcf";
|
||||
|
||||
ContentValues values = new ContentValues(2);
|
||||
values.put(COLUMN_FILENAME, newFileName);
|
||||
values.put(COLUMN_UID, uid);
|
||||
update(values);
|
||||
|
||||
fileName = newFileName;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ContentValues contentValues() {
|
||||
ContentValues values = super.contentValues();
|
||||
|
||||
@Cleanup("recycle") Parcel members = Parcel.obtain();
|
||||
members.writeStringList(contact.members);
|
||||
values.put(COLUMN_PENDING_MEMBERS, members.marshall());
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Marks all members of the current group as dirty.
|
||||
*/
|
||||
public void markMembersDirty() throws ContactsStorageException {
|
||||
assertID();
|
||||
BatchOperation batch = new BatchOperation(addressBook.provider);
|
||||
|
||||
for (long member : getMembers())
|
||||
batch.enqueue(ContentProviderOperation
|
||||
.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, member)))
|
||||
.withValue(RawContacts.DIRTY, 1)
|
||||
.withYieldAllowed(true)
|
||||
.build()
|
||||
);
|
||||
|
||||
batch.commit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes all groups with non-null {@link #COLUMN_PENDING_MEMBERS}: the pending memberships
|
||||
* are (if possible) applied, keeping cached memberships in sync.
|
||||
* @param addressBook address book to take groups from
|
||||
* @throws ContactsStorageException on contact provider errors
|
||||
*/
|
||||
public static void applyPendingMemberships(LocalAddressBook addressBook) throws ContactsStorageException {
|
||||
try {
|
||||
@Cleanup Cursor cursor = addressBook.provider.query(
|
||||
addressBook.syncAdapterURI(Groups.CONTENT_URI),
|
||||
new String[] { Groups._ID, COLUMN_PENDING_MEMBERS },
|
||||
COLUMN_PENDING_MEMBERS + " IS NOT NULL", new String[] {},
|
||||
null
|
||||
);
|
||||
|
||||
BatchOperation batch = new BatchOperation(addressBook.provider);
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
long id = cursor.getLong(0);
|
||||
Constants.log.fine("Assigning members to group " + id);
|
||||
|
||||
// delete all memberships and cached memberships for this group
|
||||
batch.enqueue(ContentProviderOperation
|
||||
.newDelete(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
|
||||
.withSelection(
|
||||
"(" + GroupMembership.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?) OR (" +
|
||||
CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?)",
|
||||
new String[] { GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(id), CachedGroupMembership.CONTENT_ITEM_TYPE, String.valueOf(id) })
|
||||
.withYieldAllowed(true)
|
||||
.build()
|
||||
);
|
||||
|
||||
// extract list of member UIDs
|
||||
List<String> members = new LinkedList<>();
|
||||
byte[] raw = cursor.getBlob(1);
|
||||
@Cleanup("recycle") Parcel parcel = Parcel.obtain();
|
||||
parcel.unmarshall(raw, 0, raw.length);
|
||||
parcel.setDataPosition(0);
|
||||
parcel.readStringList(members);
|
||||
|
||||
// insert memberships
|
||||
for (String uid : members) {
|
||||
Constants.log.fine("Assigning member: " + uid);
|
||||
try {
|
||||
LocalContact member = addressBook.findContactByUID(uid);
|
||||
member.addToGroup(batch, id);
|
||||
} catch(FileNotFoundException e) {
|
||||
Constants.log.log(Level.WARNING, "Group member not found: " + uid, e);
|
||||
}
|
||||
}
|
||||
|
||||
// remove pending memberships
|
||||
batch.enqueue(ContentProviderOperation
|
||||
.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, id)))
|
||||
.withValue(COLUMN_PENDING_MEMBERS, null)
|
||||
.withYieldAllowed(true)
|
||||
.build()
|
||||
);
|
||||
|
||||
batch.commit();
|
||||
}
|
||||
} catch(RemoteException e) {
|
||||
throw new ContactsStorageException("Couldn't get pending memberships", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private void assertID() {
|
||||
if (id == null)
|
||||
throw new IllegalStateException("Group has not been saved yet");
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all members of this group.
|
||||
* @return list of all members' raw contact IDs
|
||||
* @throws ContactsStorageException on contact provider errors
|
||||
*/
|
||||
protected long[] getMembers() throws ContactsStorageException {
|
||||
assertID();
|
||||
List<Long> members = new LinkedList<>();
|
||||
try {
|
||||
@Cleanup Cursor cursor = addressBook.provider.query(
|
||||
addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI),
|
||||
new String[] { Data.RAW_CONTACT_ID },
|
||||
GroupMembership.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?",
|
||||
new String[] { GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(id) },
|
||||
null
|
||||
);
|
||||
while (cursor != null && cursor.moveToNext())
|
||||
members.add(cursor.getLong(0));
|
||||
} catch(RemoteException e) {
|
||||
throw new ContactsStorageException("Couldn't list group members", e);
|
||||
}
|
||||
return ArrayUtils.toPrimitive(members.toArray(new Long[members.size()]));
|
||||
}
|
||||
|
||||
|
||||
// factory
|
||||
|
||||
static class Factory extends AndroidGroupFactory {
|
||||
static final Factory INSTANCE = new Factory();
|
||||
|
||||
@Override
|
||||
public LocalGroup newInstance(AndroidAddressBook addressBook, long id, String fileName, String eTag) {
|
||||
return new LocalGroup(addressBook, id, fileName, eTag);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalGroup newInstance(AndroidAddressBook addressBook, Contact contact, String fileName, String eTag) {
|
||||
return new LocalGroup(addressBook, contact, fileName, eTag);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalGroup[] newArray(int size) {
|
||||
return new LocalGroup[size];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
246
app/src/main/java/at/bitfire/davdroid/resource/LocalGroup.kt
Normal file
246
app/src/main/java/at/bitfire/davdroid/resource/LocalGroup.kt
Normal file
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Parcel
|
||||
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.provider.ContactsContract.RawContacts.Data
|
||||
import at.bitfire.dav4jvm.Constants
|
||||
import at.bitfire.vcard4android.*
|
||||
import java.util.*
|
||||
|
||||
class LocalGroup: AndroidGroup, LocalAddress {
|
||||
|
||||
companion object {
|
||||
|
||||
const val COLUMN_FLAGS = Groups.SYNC4
|
||||
|
||||
/** marshaled list of member UIDs, as sent by server */
|
||||
const val COLUMN_PENDING_MEMBERS = Groups.SYNC3
|
||||
|
||||
/**
|
||||
* Processes all groups with non-null {@link #COLUMN_PENDING_MEMBERS}: the pending memberships
|
||||
* are (if possible) applied, keeping cached memberships in sync.
|
||||
* @param addressBook address book to take groups from
|
||||
*/
|
||||
fun applyPendingMemberships(addressBook: LocalAddressBook) {
|
||||
addressBook.provider!!.query(
|
||||
addressBook.groupsSyncUri(),
|
||||
arrayOf(Groups._ID, COLUMN_PENDING_MEMBERS),
|
||||
"$COLUMN_PENDING_MEMBERS IS NOT NULL", null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
val batch = BatchOperation(addressBook.provider)
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(0)
|
||||
Constants.log.fine("Assigning members to group $id")
|
||||
|
||||
// required for workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
val changeContactIDs = HashSet<Long>()
|
||||
|
||||
// delete all memberships and cached memberships for this group
|
||||
for (contact in addressBook.getByGroupMembership(id)) {
|
||||
contact.removeGroupMemberships(batch)
|
||||
changeContactIDs += contact.id!!
|
||||
}
|
||||
|
||||
// extract list of member UIDs
|
||||
val members = LinkedList<String>()
|
||||
val raw = cursor.getBlob(1)
|
||||
val parcel = Parcel.obtain()
|
||||
try {
|
||||
parcel.unmarshall(raw, 0, raw.size)
|
||||
parcel.setDataPosition(0)
|
||||
parcel.readStringList(members)
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
|
||||
// insert memberships
|
||||
for (uid in members) {
|
||||
Constants.log.fine("Assigning member: $uid")
|
||||
addressBook.findContactByUID(uid)?.let { member ->
|
||||
member.addToGroup(batch, id)
|
||||
changeContactIDs += member.id!!
|
||||
} ?: Constants.log.warning("Group member not found: $uid")
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
changeContactIDs
|
||||
.map { addressBook.findContactByID(it) }
|
||||
.forEach { it.updateHashCode(batch) }
|
||||
|
||||
// remove pending memberships
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, id)))
|
||||
.withValue(COLUMN_PENDING_MEMBERS, null)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
override var flags: Int = 0
|
||||
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, values: ContentValues)
|
||||
: super(addressBook, values) {
|
||||
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
|
||||
}
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, contact: Contact, fileName: String?, eTag: String?, flags: Int)
|
||||
: super(addressBook, contact, fileName, eTag) {
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
|
||||
override fun contentValues(): ContentValues {
|
||||
val values = super.contentValues()
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
|
||||
val members = Parcel.obtain()
|
||||
try {
|
||||
members.writeStringList(contact!!.members)
|
||||
values.put(COLUMN_PENDING_MEMBERS, members.marshall())
|
||||
} finally {
|
||||
members.recycle()
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
|
||||
override fun assignNameAndUID() {
|
||||
val uid = UUID.randomUUID().toString()
|
||||
val newFileName = "$uid.vcf"
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(COLUMN_FILENAME, newFileName)
|
||||
values.put(COLUMN_UID, uid)
|
||||
update(values)
|
||||
|
||||
fileName = newFileName
|
||||
}
|
||||
|
||||
override fun clearDirty(eTag: String?) {
|
||||
val id = requireNotNull(id)
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(Groups.DIRTY, 0)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
this.eTag = eTag
|
||||
update(values)
|
||||
|
||||
// update cached group memberships
|
||||
val batch = BatchOperation(addressBook.provider!!)
|
||||
|
||||
// delete cached group memberships
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newDelete(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
|
||||
.withSelection(
|
||||
CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?",
|
||||
arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE, id.toString())
|
||||
)
|
||||
))
|
||||
|
||||
// insert updated cached group memberships
|
||||
for (member in getMembers())
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newInsert(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
|
||||
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
.withValue(CachedGroupMembership.RAW_CONTACT_ID, member)
|
||||
.withValue(CachedGroupMembership.GROUP_ID, id)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks all members of the current group as dirty.
|
||||
*/
|
||||
fun markMembersDirty() {
|
||||
val batch = BatchOperation(addressBook.provider!!)
|
||||
|
||||
for (member in getMembers())
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, member)))
|
||||
.withValue(RawContacts.DIRTY, 1)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
val values = ContentValues(1)
|
||||
values.put(Groups.DELETED, 0)
|
||||
addressBook.provider!!.update(groupSyncUri(), values, null, null)
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
val values = ContentValues(1)
|
||||
values.put(LocalGroup.COLUMN_FLAGS, flags)
|
||||
addressBook.provider!!.update(groupSyncUri(), values, null, null)
|
||||
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun groupSyncUri(): Uri {
|
||||
val id = requireNotNull(id)
|
||||
return ContentUris.withAppendedId(addressBook.groupsSyncUri(), id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all members of this group.
|
||||
* @return list of all members' raw contact IDs
|
||||
* @throws RemoteException on contact provider errors
|
||||
*/
|
||||
internal fun getMembers(): List<Long> {
|
||||
val id = requireNotNull(id)
|
||||
val members = LinkedList<Long>()
|
||||
addressBook.provider!!.query(
|
||||
addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI),
|
||||
arrayOf(Data.RAW_CONTACT_ID),
|
||||
"${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?",
|
||||
arrayOf(GroupMembership.CONTENT_ITEM_TYPE, id.toString()),
|
||||
null
|
||||
)?.use { cursor ->
|
||||
while (cursor.moveToNext())
|
||||
members += cursor.getLong(0)
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
|
||||
// factory
|
||||
|
||||
object Factory: AndroidGroupFactory<LocalGroup> {
|
||||
override fun fromProvider(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, values: ContentValues) =
|
||||
LocalGroup(addressBook, values)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource;
|
||||
|
||||
import at.bitfire.ical4android.CalendarStorageException;
|
||||
import at.bitfire.vcard4android.ContactsStorageException;
|
||||
|
||||
public interface LocalResource {
|
||||
|
||||
Long getId();
|
||||
|
||||
String getFileName();
|
||||
String getETag();
|
||||
|
||||
int delete() throws CalendarStorageException, ContactsStorageException;
|
||||
|
||||
void updateFileNameAndUID(String uuid) throws CalendarStorageException, ContactsStorageException;
|
||||
void clearDirty(String eTag) throws CalendarStorageException, ContactsStorageException;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
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?
|
||||
|
||||
val fileName: String?
|
||||
var eTag: String?
|
||||
val flags: Int
|
||||
|
||||
fun assignNameAndUID()
|
||||
fun clearDirty(eTag: String?)
|
||||
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
|
||||
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource;
|
||||
|
||||
import android.content.ContentProviderOperation;
|
||||
import android.content.ContentValues;
|
||||
import android.os.RemoteException;
|
||||
import android.provider.CalendarContract.Events;
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
import net.fortuna.ical4j.model.property.ProdId;
|
||||
|
||||
import org.dmfs.provider.tasks.TaskContract.Tasks;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.text.ParseException;
|
||||
|
||||
import at.bitfire.davdroid.BuildConfig;
|
||||
import at.bitfire.ical4android.AndroidTask;
|
||||
import at.bitfire.ical4android.AndroidTaskFactory;
|
||||
import at.bitfire.ical4android.AndroidTaskList;
|
||||
import at.bitfire.ical4android.CalendarStorageException;
|
||||
import at.bitfire.ical4android.Task;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
public class LocalTask extends AndroidTask implements LocalResource {
|
||||
static {
|
||||
Task.prodId = new ProdId("+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ical4android ical4j/2.x");
|
||||
}
|
||||
|
||||
static final String COLUMN_ETAG = Tasks.SYNC1,
|
||||
COLUMN_UID = Tasks.SYNC2,
|
||||
COLUMN_SEQUENCE = Tasks.SYNC3;
|
||||
|
||||
@Getter protected String fileName;
|
||||
@Getter @Setter protected String eTag;
|
||||
|
||||
public LocalTask(@NonNull AndroidTaskList taskList, Task task, String fileName, String eTag) {
|
||||
super(taskList, task);
|
||||
this.fileName = fileName;
|
||||
this.eTag = eTag;
|
||||
}
|
||||
|
||||
protected LocalTask(@NonNull AndroidTaskList taskList, long id, ContentValues baseInfo) {
|
||||
super(taskList, id);
|
||||
if (baseInfo != null) {
|
||||
fileName = baseInfo.getAsString(Events._SYNC_ID);
|
||||
eTag = baseInfo.getAsString(COLUMN_ETAG);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* process LocalTask-specific fields */
|
||||
|
||||
@Override
|
||||
protected void populateTask(ContentValues values) throws FileNotFoundException, RemoteException, ParseException {
|
||||
super.populateTask(values);
|
||||
|
||||
fileName = values.getAsString(Events._SYNC_ID);
|
||||
eTag = values.getAsString(COLUMN_ETAG);
|
||||
task.uid = values.getAsString(COLUMN_UID);
|
||||
|
||||
task.sequence = values.getAsInteger(COLUMN_SEQUENCE);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void buildTask(ContentProviderOperation.Builder builder, boolean update) {
|
||||
super.buildTask(builder, update);
|
||||
builder .withValue(Tasks._SYNC_ID, fileName)
|
||||
.withValue(COLUMN_UID, task.uid)
|
||||
.withValue(COLUMN_SEQUENCE, task.sequence)
|
||||
.withValue(COLUMN_ETAG, eTag);
|
||||
}
|
||||
|
||||
|
||||
/* custom queries */
|
||||
|
||||
public void updateFileNameAndUID(String uid) throws CalendarStorageException {
|
||||
try {
|
||||
String newFileName = uid + ".ics";
|
||||
|
||||
ContentValues values = new ContentValues(2);
|
||||
values.put(Tasks._SYNC_ID, newFileName);
|
||||
values.put(COLUMN_UID, uid);
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null);
|
||||
|
||||
fileName = newFileName;
|
||||
if (task != null)
|
||||
task.uid = uid;
|
||||
|
||||
} catch (RemoteException e) {
|
||||
throw new CalendarStorageException("Couldn't update UID", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearDirty(String eTag) throws CalendarStorageException {
|
||||
try {
|
||||
ContentValues values = new ContentValues(2);
|
||||
values.put(Tasks._DIRTY, 0);
|
||||
values.put(COLUMN_ETAG, eTag);
|
||||
if (task != null)
|
||||
values.put(COLUMN_SEQUENCE, task.sequence);
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null);
|
||||
|
||||
this.eTag = eTag;
|
||||
} catch (RemoteException e) {
|
||||
throw new CalendarStorageException("Couldn't update _DIRTY/ETag/SEQUENCE", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static class Factory implements AndroidTaskFactory {
|
||||
static final Factory INSTANCE = new Factory();
|
||||
|
||||
@Override
|
||||
public LocalTask newInstance(AndroidTaskList taskList, long id, ContentValues baseInfo) {
|
||||
return new LocalTask(taskList, id, baseInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalTask newInstance(AndroidTaskList taskList, Task task) {
|
||||
return new LocalTask(taskList, task, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalTask[] newArray(int size) {
|
||||
return new LocalTask[size];
|
||||
}
|
||||
}
|
||||
}
|
||||
101
app/src/main/java/at/bitfire/davdroid/resource/LocalTask.kt
Normal file
101
app/src/main/java/at/bitfire/davdroid/resource/LocalTask.kt
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentValues
|
||||
import at.bitfire.ical4android.AndroidTask
|
||||
import at.bitfire.ical4android.AndroidTaskFactory
|
||||
import at.bitfire.ical4android.AndroidTaskList
|
||||
import at.bitfire.ical4android.Task
|
||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||
import java.util.*
|
||||
|
||||
class LocalTask: AndroidTask, LocalResource<Task> {
|
||||
|
||||
companion object {
|
||||
const val COLUMN_ETAG = Tasks.SYNC1
|
||||
const val COLUMN_FLAGS = Tasks.SYNC2
|
||||
}
|
||||
|
||||
override var fileName: String? = null
|
||||
override var eTag: String? = null
|
||||
|
||||
override var flags = 0
|
||||
private set
|
||||
|
||||
|
||||
constructor(taskList: AndroidTaskList<*>, task: Task, fileName: String?, eTag: String?, flags: Int)
|
||||
: super(taskList, task) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
private constructor(taskList: AndroidTaskList<*>, 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: ContentProviderOperation.Builder, update: Boolean) {
|
||||
super.buildTask(builder, update)
|
||||
|
||||
builder .withValue(Tasks._SYNC_ID, fileName)
|
||||
.withValue(COLUMN_ETAG, eTag)
|
||||
.withValue(COLUMN_FLAGS, flags)
|
||||
}
|
||||
|
||||
|
||||
/* custom queries */
|
||||
|
||||
override fun assignNameAndUID() {
|
||||
val uid = UUID.randomUUID().toString()
|
||||
val newFileName = "$uid.ics"
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(Tasks._SYNC_ID, newFileName)
|
||||
values.put(Tasks._UID, uid)
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null)
|
||||
|
||||
fileName = newFileName
|
||||
|
||||
task!!.uid = uid
|
||||
}
|
||||
|
||||
override fun clearDirty(eTag: String?) {
|
||||
val values = ContentValues(3)
|
||||
values.put(Tasks._DIRTY, 0)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(Tasks.SYNC_VERSION, task!!.sequence)
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
object Factory: AndroidTaskFactory<LocalTask> {
|
||||
override fun fromProvider(taskList: AndroidTaskList<*>, values: ContentValues) =
|
||||
LocalTask(taskList, values)
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.RemoteException;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import org.dmfs.provider.tasks.TaskContract.TaskLists;
|
||||
import org.dmfs.provider.tasks.TaskContract.Tasks;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
|
||||
import at.bitfire.davdroid.DavUtils;
|
||||
import at.bitfire.davdroid.model.CollectionInfo;
|
||||
import at.bitfire.ical4android.AndroidTaskList;
|
||||
import at.bitfire.ical4android.AndroidTaskListFactory;
|
||||
import at.bitfire.ical4android.CalendarStorageException;
|
||||
import at.bitfire.ical4android.TaskProvider;
|
||||
import lombok.Cleanup;
|
||||
|
||||
public class LocalTaskList extends AndroidTaskList implements LocalCollection {
|
||||
|
||||
public static final int defaultColor = 0xFFC3EA6E; // "DAVdroid green"
|
||||
|
||||
public static final String COLUMN_CTAG = TaskLists.SYNC_VERSION;
|
||||
|
||||
static String[] BASE_INFO_COLUMNS = new String[] {
|
||||
Tasks._ID,
|
||||
Tasks._SYNC_ID,
|
||||
LocalTask.COLUMN_ETAG
|
||||
};
|
||||
|
||||
// can be cached, because after installing OpenTasks, you have to re-install DAVdroid anyway
|
||||
private static Boolean tasksProviderAvailable;
|
||||
|
||||
|
||||
@Override
|
||||
protected String[] taskBaseInfoColumns() {
|
||||
return BASE_INFO_COLUMNS;
|
||||
}
|
||||
|
||||
|
||||
protected LocalTaskList(Account account, TaskProvider provider, long id) {
|
||||
super(account, provider, LocalTask.Factory.INSTANCE, id);
|
||||
}
|
||||
|
||||
public static Uri create(Account account, TaskProvider provider, CollectionInfo info) throws CalendarStorageException {
|
||||
ContentValues 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);
|
||||
}
|
||||
|
||||
public void update(CollectionInfo info, boolean updateColor) throws CalendarStorageException {
|
||||
update(valuesFromCollectionInfo(info, updateColor));
|
||||
}
|
||||
|
||||
private static ContentValues valuesFromCollectionInfo(CollectionInfo info, boolean withColor) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(TaskLists._SYNC_ID, info.url);
|
||||
values.put(TaskLists.LIST_NAME, !TextUtils.isEmpty(info.displayName) ? info.displayName : DavUtils.lastSegmentOfUrl(info.url));
|
||||
|
||||
if (withColor)
|
||||
values.put(TaskLists.LIST_COLOR, info.color != null ? info.color : defaultColor);
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public LocalTask[] getAll() throws CalendarStorageException {
|
||||
return (LocalTask[])queryTasks(null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalTask[] getDeleted() throws CalendarStorageException {
|
||||
return (LocalTask[])queryTasks(Tasks._DELETED + "!=0", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalTask[] getWithoutFileName() throws CalendarStorageException {
|
||||
return (LocalTask[])queryTasks(Tasks._SYNC_ID + " IS NULL", null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public LocalResource[] getDirty() throws CalendarStorageException, FileNotFoundException {
|
||||
LocalTask[] tasks = (LocalTask[])queryTasks(Tasks._DIRTY + "!=0", null);
|
||||
if (tasks != null)
|
||||
for (LocalTask task : tasks) {
|
||||
if (task.getTask().sequence == null) // sequence has not been assigned yet (i.e. this task was just locally created)
|
||||
task.getTask().sequence = 0;
|
||||
else
|
||||
task.getTask().sequence++;
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("Recycle")
|
||||
public String getCTag() throws CalendarStorageException {
|
||||
try {
|
||||
@Cleanup Cursor cursor = provider.client.query(taskListSyncUri(), new String[] { COLUMN_CTAG }, null, null, null);
|
||||
if (cursor != null && cursor.moveToNext())
|
||||
return cursor.getString(0);
|
||||
} catch (RemoteException e) {
|
||||
throw new CalendarStorageException("Couldn't read local (last known) CTag", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setCTag(String cTag) throws CalendarStorageException {
|
||||
try {
|
||||
ContentValues values = new ContentValues(1);
|
||||
values.put(COLUMN_CTAG, cTag);
|
||||
provider.client.update(taskListSyncUri(), values, null, null);
|
||||
} catch (RemoteException e) {
|
||||
throw new CalendarStorageException("Couldn't write local (last known) CTag", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
public static boolean tasksProviderAvailable(@NonNull Context context) {
|
||||
if (tasksProviderAvailable != null)
|
||||
return tasksProviderAvailable;
|
||||
else {
|
||||
if (Build.VERSION.SDK_INT >= 23)
|
||||
return context.getPackageManager().resolveContentProvider(TaskProvider.ProviderName.OpenTasks.authority, 0) != null;
|
||||
else {
|
||||
@Cleanup TaskProvider provider = TaskProvider.acquire(context.getContentResolver(), TaskProvider.ProviderName.OpenTasks);
|
||||
return tasksProviderAvailable = (provider != null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static class Factory implements AndroidTaskListFactory {
|
||||
public static final Factory INSTANCE = new Factory();
|
||||
|
||||
@Override
|
||||
public AndroidTaskList newInstance(Account account, TaskProvider provider, long id) {
|
||||
return new LocalTaskList(account, provider, id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AndroidTaskList[] newArray(int size) {
|
||||
return new LocalTaskList[size];
|
||||
}
|
||||
}
|
||||
}
|
||||
160
app/src/main/java/at/bitfire/davdroid/resource/LocalTaskList.kt
Normal file
160
app/src/main/java/at/bitfire/davdroid/resource/LocalTaskList.kt
Normal file
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.DavUtils
|
||||
import at.bitfire.davdroid.closeCompat
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.ical4android.AndroidTaskList
|
||||
import at.bitfire.ical4android.AndroidTaskListFactory
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import org.dmfs.tasks.contract.TaskContract.TaskLists
|
||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||
import java.util.logging.Level
|
||||
|
||||
class LocalTaskList private constructor(
|
||||
account: Account,
|
||||
provider: TaskProvider,
|
||||
id: Long
|
||||
): AndroidTaskList<LocalTask>(account, provider, LocalTask.Factory, id), LocalCollection<LocalTask> {
|
||||
|
||||
companion object {
|
||||
|
||||
fun tasksProviderAvailable(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||
return context.packageManager.resolveContentProvider(TaskProvider.ProviderName.OpenTasks.authority, 0) != null
|
||||
else
|
||||
try {
|
||||
TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use {
|
||||
return true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// couldn't acquire task provider
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun create(account: Account, provider: TaskProvider, info: CollectionInfo): 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(resolver: ContentResolver, oldName: String, newName: String) {
|
||||
var client: ContentProviderClient? = null
|
||||
try {
|
||||
client = resolver.acquireContentProviderClient(TaskProvider.ProviderName.OpenTasks.authority)
|
||||
client?.use {
|
||||
val values = ContentValues(1)
|
||||
values.put(Tasks.ACCOUNT_NAME, newName)
|
||||
it.update(Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority), values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldName))
|
||||
}
|
||||
} finally {
|
||||
client?.closeCompat()
|
||||
}
|
||||
}
|
||||
|
||||
private fun valuesFromCollectionInfo(info: CollectionInfo, 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)
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
fun update(info: CollectionInfo, updateColor: Boolean) =
|
||||
update(valuesFromCollectionInfo(info, updateColor))
|
||||
|
||||
|
||||
override fun findDeleted() = queryTasks("${Tasks._DELETED}!=0", null)
|
||||
|
||||
override fun findDirty(): List<LocalTask> {
|
||||
val tasks = queryTasks("${Tasks._DIRTY}!=0", null)
|
||||
for (localTask in tasks) {
|
||||
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.sequence = sequence + 1
|
||||
}
|
||||
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 ${Tasks._DIRTY}=0 AND ${LocalTask.COLUMN_FLAGS}=?",
|
||||
arrayOf(id.toString(), flags.toString()))
|
||||
|
||||
|
||||
object Factory: AndroidTaskListFactory<LocalTaskList> {
|
||||
|
||||
override fun newInstance(account: Account, provider: TaskProvider, id: Long) =
|
||||
LocalTaskList(account, provider, id)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.*
|
||||
import android.os.Bundle
|
||||
import android.os.Parcel
|
||||
import android.os.RemoteException
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import at.bitfire.davdroid.*
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.Credentials
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.ServiceDB.*
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalCalendar
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.AndroidTaskList
|
||||
import at.bitfire.ical4android.CalendarStorageException
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.ical4android.TaskProvider.ProviderName.OpenTasks
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import okhttp3.HttpUrl
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.dmfs.tasks.contract.TaskContract
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Manages settings of an account.
|
||||
*
|
||||
* @throws InvalidAccountException on construction when the account doesn't exist (anymore)
|
||||
*/
|
||||
class AccountSettings(
|
||||
val context: Context,
|
||||
val account: Account
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
const val CURRENT_VERSION = 9
|
||||
const val KEY_SETTINGS_VERSION = "version"
|
||||
|
||||
const val KEY_USERNAME = "user_name"
|
||||
const val KEY_CERTIFICATE_ALIAS = "certificate_alias"
|
||||
|
||||
const val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false)
|
||||
const val WIFI_ONLY_DEFAULT = false
|
||||
const val KEY_WIFI_ONLY_SSIDS = "wifi_only_ssids" // restrict sync to specific WiFi SSIDs
|
||||
|
||||
/** Time range limitation to the past [in days]
|
||||
value = null default value (DEFAULT_TIME_RANGE_PAST_DAYS)
|
||||
< 0 (-1) no limit
|
||||
>= 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 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:
|
||||
value = null (not existing) groups as separate VCards (default)
|
||||
"CATEGORIES" groups are per-contact CATEGORIES
|
||||
*/
|
||||
const val KEY_CONTACT_GROUP_METHOD = "contact_group_method"
|
||||
|
||||
const val SYNC_INTERVAL_MANUALLY = -1L
|
||||
|
||||
fun initialUserData(credentials: Credentials): Bundle {
|
||||
val bundle = Bundle(2)
|
||||
bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
|
||||
|
||||
when (credentials.type) {
|
||||
Credentials.Type.UsernamePassword ->
|
||||
bundle.putString(KEY_USERNAME, credentials.userName)
|
||||
Credentials.Type.ClientCertificate ->
|
||||
bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
|
||||
}
|
||||
|
||||
return bundle
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
val accountManager: AccountManager = AccountManager.get(context)
|
||||
val settings = Settings.getInstance(context)
|
||||
|
||||
init {
|
||||
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.fine("Account ${account.name} has version $version, current version: $CURRENT_VERSION")
|
||||
|
||||
if (version < CURRENT_VERSION)
|
||||
update(version)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// authentication settings
|
||||
|
||||
fun credentials() = Credentials(
|
||||
accountManager.getUserData(account, KEY_USERNAME),
|
||||
accountManager.getPassword(account),
|
||||
accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS)
|
||||
)
|
||||
|
||||
fun credentials(credentials: Credentials) {
|
||||
accountManager.setUserData(account, KEY_USERNAME, credentials.userName)
|
||||
accountManager.setPassword(account, credentials.password)
|
||||
accountManager.setUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
|
||||
}
|
||||
|
||||
|
||||
// sync. settings
|
||||
|
||||
fun getSyncInterval(authority: String): Long? {
|
||||
if (ContentResolver.getIsSyncable(account, authority) <= 0)
|
||||
return null
|
||||
|
||||
return if (ContentResolver.getSyncAutomatically(account, authority))
|
||||
ContentResolver.getPeriodicSyncs(account, authority).firstOrNull()?.period ?: SYNC_INTERVAL_MANUALLY
|
||||
else
|
||||
SYNC_INTERVAL_MANUALLY
|
||||
}
|
||||
|
||||
fun setSyncInterval(authority: String, seconds: Long) {
|
||||
if (seconds == SYNC_INTERVAL_MANUALLY) {
|
||||
ContentResolver.setSyncAutomatically(account, authority, false)
|
||||
} else {
|
||||
ContentResolver.setSyncAutomatically(account, authority, true)
|
||||
ContentResolver.addPeriodicSync(account, authority, Bundle(), seconds)
|
||||
}
|
||||
}
|
||||
|
||||
fun getSyncWifiOnly() = if (settings.has(KEY_WIFI_ONLY))
|
||||
settings.getBoolean(KEY_WIFI_ONLY) ?: WIFI_ONLY_DEFAULT
|
||||
else
|
||||
accountManager.getUserData(account, KEY_WIFI_ONLY) != null
|
||||
fun setSyncWiFiOnly(wiFiOnly: Boolean) =
|
||||
accountManager.setUserData(account, KEY_WIFI_ONLY, if (wiFiOnly) "1" else null)
|
||||
|
||||
fun getSyncWifiOnlySSIDs(): List<String>? = (if (settings.has(KEY_WIFI_ONLY_SSIDS))
|
||||
settings.getString(KEY_WIFI_ONLY_SSIDS)
|
||||
else
|
||||
accountManager.getUserData(account, KEY_WIFI_ONLY_SSIDS))?.split(',')
|
||||
fun setSyncWifiOnlySSIDs(ssids: List<String>?) =
|
||||
accountManager.setUserData(account, KEY_WIFI_ONLY_SSIDS, StringUtils.trimToNull(ssids?.joinToString(",")))
|
||||
|
||||
|
||||
// CalDAV settings
|
||||
|
||||
fun getTimeRangePastDays(): Int? {
|
||||
val strDays = accountManager.getUserData(account, KEY_TIME_RANGE_PAST_DAYS)
|
||||
return if (strDays != null) {
|
||||
val days = Integer.valueOf(strDays)
|
||||
if (days < 0) null else days
|
||||
} else
|
||||
DEFAULT_TIME_RANGE_PAST_DAYS
|
||||
}
|
||||
|
||||
fun setTimeRangePastDays(days: Int?) =
|
||||
accountManager.setUserData(account, KEY_TIME_RANGE_PAST_DAYS, (days ?: -1).toString())
|
||||
|
||||
fun getManageCalendarColors() = if (settings.has(KEY_MANAGE_CALENDAR_COLORS))
|
||||
settings.getBoolean(KEY_MANAGE_CALENDAR_COLORS) ?: false
|
||||
else
|
||||
accountManager.getUserData(account, KEY_MANAGE_CALENDAR_COLORS) == null
|
||||
fun setManageCalendarColors(manage: Boolean) =
|
||||
accountManager.setUserData(account, KEY_MANAGE_CALENDAR_COLORS, if (manage) null else "0")
|
||||
|
||||
fun getEventColors() = if (settings.has(KEY_EVENT_COLORS))
|
||||
settings.getBoolean(KEY_EVENT_COLORS) ?: false
|
||||
else
|
||||
accountManager.getUserData(account, KEY_EVENT_COLORS) != null
|
||||
fun setEventColors(useColors: Boolean) =
|
||||
accountManager.setUserData(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 (e: IllegalArgumentException) {
|
||||
}
|
||||
return GroupMethod.GROUP_VCARDS
|
||||
}
|
||||
|
||||
fun setGroupMethod(method: GroupMethod) {
|
||||
accountManager.setUserData(account, KEY_CONTACT_GROUP_METHOD, method.name)
|
||||
}
|
||||
|
||||
|
||||
// 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 updateProc = this::class.java.getDeclaredMethod("update_${fromVersion}_$toVersion")
|
||||
updateProc.invoke(this)
|
||||
|
||||
Logger.log.info("Account version update successful")
|
||||
accountManager.setUserData(account, KEY_SETTINGS_VERSION, toVersion.toString())
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't update account settings", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Suppress("unused")
|
||||
@SuppressLint("Recycle")
|
||||
/**
|
||||
* 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() {
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.readableDatabase
|
||||
db.query(ServiceDB.Services._TABLE, null, "${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
|
||||
arrayOf(account.name, ServiceDB.Services.SERVICE_CALDAV), null, null, null).use { result ->
|
||||
val hasCalDAV = result.count >= 1
|
||||
if (!hasCalDAV && ContentResolver.getIsSyncable(account, OpenTasks.authority) != 0) {
|
||||
Logger.log.info("Disabling OpenTasks sync for $account")
|
||||
ContentResolver.setIsSyncable(account, OpenTasks.authority, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@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)?.let { provider ->
|
||||
// ETag is now in sync_version instead of sync1
|
||||
// UID is now in _uid instead of sync2
|
||||
provider.client.query(TaskProvider.syncAdapterUri(provider.tasksUri(), 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(
|
||||
TaskProvider.syncAdapterUri(ContentUris.withAppendedId(provider.tasksUri(), id), account),
|
||||
values, null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@SuppressLint("Recycle")
|
||||
private fun update_6_7() {
|
||||
// add calendar colors
|
||||
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider ->
|
||||
try {
|
||||
AndroidCalendar.insertColors(provider, account)
|
||||
} finally {
|
||||
provider.closeCompat()
|
||||
}
|
||||
}
|
||||
|
||||
// update allowed WiFi settings key
|
||||
val onlySSID = accountManager.getUserData(account, "wifi_only_ssid")
|
||||
accountManager.setUserData(account, KEY_WIFI_ONLY_SSIDS, onlySSID)
|
||||
accountManager.setUserData(account, "wifi_only_ssid", null)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@SuppressLint("Recycle", "ParcelClassLoader")
|
||||
private fun update_5_6() {
|
||||
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { 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")?.let { HttpUrl.parse(it) }
|
||||
if (url == null)
|
||||
Logger.log.info("No address book URL, ignoring account")
|
||||
else {
|
||||
// create new address book
|
||||
val info = CollectionInfo(url)
|
||||
info.type = CollectionInfo.Type.ADDRESS_BOOK
|
||||
info.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()
|
||||
provider.closeCompat()
|
||||
}
|
||||
}
|
||||
|
||||
// update version number so that further syncs don't repeat the migration
|
||||
accountManager.setUserData(account, KEY_SETTINGS_VERSION, "6")
|
||||
|
||||
// request sync of new address book account
|
||||
ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 1)
|
||||
setSyncInterval(context.getString(R.string.address_books_authority), Constants.DEFAULT_SYNC_INTERVAL)
|
||||
}
|
||||
|
||||
/* 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
|
||||
PackageChangedReceiver.updateTaskSync(context)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun update_3_4() {
|
||||
setGroupMethod(GroupMethod.CATEGORIES)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@SuppressLint("Recycle")
|
||||
private fun update_2_3() {
|
||||
// Don't show a warning for Android updates anymore
|
||||
accountManager.setUserData(account, "last_android_version", null)
|
||||
|
||||
var serviceCardDAV: Long? = null
|
||||
var serviceCalDAV: Long? = null
|
||||
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.writableDatabase
|
||||
// we have to create the WebDAV Service database only from the old address book, calendar and task list URLs
|
||||
|
||||
// CardDAV: migrate address books
|
||||
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { client ->
|
||||
try {
|
||||
val addrBook = LocalAddressBook(context, account, client)
|
||||
val url = addrBook.url
|
||||
Logger.log.fine("Migrating address book $url")
|
||||
|
||||
// insert CardDAV service
|
||||
val values = ContentValues(3)
|
||||
values.put(Services.ACCOUNT_NAME, account.name)
|
||||
values.put(Services.SERVICE, Services.SERVICE_CARDDAV)
|
||||
serviceCardDAV = db.insert(Services._TABLE, null, values)
|
||||
|
||||
// insert address book
|
||||
values.clear()
|
||||
values.put(Collections.SERVICE_ID, serviceCardDAV)
|
||||
values.put(Collections.URL, url)
|
||||
values.put(Collections.SYNC, 1)
|
||||
db.insert(Collections._TABLE, null, values)
|
||||
|
||||
// insert home set
|
||||
HttpUrl.parse(url)?.let {
|
||||
val homeSet = it.resolve("../")
|
||||
values.clear()
|
||||
values.put(HomeSets.SERVICE_ID, serviceCardDAV)
|
||||
values.put(HomeSets.URL, homeSet.toString())
|
||||
db.insert(HomeSets._TABLE, null, values)
|
||||
}
|
||||
} catch (e: ContactsStorageException) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't migrate address book", e)
|
||||
} finally {
|
||||
client.closeCompat()
|
||||
}
|
||||
}
|
||||
|
||||
// CalDAV: migrate calendars + task lists
|
||||
val collections = HashSet<String>()
|
||||
val homeSets = HashSet<HttpUrl>()
|
||||
|
||||
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { client ->
|
||||
try {
|
||||
val calendars = AndroidCalendar.find(account, client, LocalCalendar.Factory, null, null)
|
||||
for (calendar in calendars)
|
||||
calendar.name?.let { url ->
|
||||
Logger.log.fine("Migrating calendar $url")
|
||||
collections.add(url)
|
||||
HttpUrl.parse(url)?.resolve("../")?.let { homeSets.add(it) }
|
||||
}
|
||||
} catch (e: CalendarStorageException) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't migrate calendars", e)
|
||||
} finally {
|
||||
client.closeCompat()
|
||||
}
|
||||
}
|
||||
|
||||
AndroidTaskList.acquireTaskProvider(context)?.use { provider ->
|
||||
try {
|
||||
val taskLists = AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null)
|
||||
for (taskList in taskLists)
|
||||
taskList.syncId?.let { url ->
|
||||
Logger.log.fine("Migrating task list $url")
|
||||
collections.add(url)
|
||||
HttpUrl.parse(url)?.resolve("../")?.let { homeSets.add(it) }
|
||||
}
|
||||
} catch (e: CalendarStorageException) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't migrate task lists", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (!collections.isEmpty()) {
|
||||
// insert CalDAV service
|
||||
val values = ContentValues(3)
|
||||
values.put(Services.ACCOUNT_NAME, account.name)
|
||||
values.put(Services.SERVICE, Services.SERVICE_CALDAV)
|
||||
serviceCalDAV = db.insert(Services._TABLE, null, values)
|
||||
|
||||
// insert collections
|
||||
for (url in collections) {
|
||||
values.clear()
|
||||
values.put(Collections.SERVICE_ID, serviceCalDAV)
|
||||
values.put(Collections.URL, url)
|
||||
values.put(Collections.SYNC, 1)
|
||||
db.insert(Collections._TABLE, null, values)
|
||||
}
|
||||
|
||||
// insert home sets
|
||||
for (homeSet in homeSets) {
|
||||
values.clear()
|
||||
values.put(HomeSets.SERVICE_ID, serviceCalDAV)
|
||||
values.put(HomeSets.URL, homeSet.toString())
|
||||
db.insert(HomeSets._TABLE, null, values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// initiate service detection (refresh) to get display names, colors etc.
|
||||
val refresh = Intent(context, DavService::class.java)
|
||||
refresh.action = DavService.ACTION_REFRESH_COLLECTIONS
|
||||
serviceCardDAV?.let {
|
||||
refresh.putExtra(DavService.EXTRA_DAV_SERVICE_ID, it)
|
||||
context.startService(refresh)
|
||||
}
|
||||
serviceCalDAV?.let {
|
||||
refresh.putExtra(DavService.EXTRA_DAV_SERVICE_ID, it)
|
||||
context.startService(refresh)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@SuppressLint("Recycle")
|
||||
private fun update_1_2() {
|
||||
/* - KEY_ADDRESSBOOK_URL ("addressbook_url"),
|
||||
- KEY_ADDRESSBOOK_CTAG ("addressbook_ctag"),
|
||||
- KEY_ADDRESSBOOK_VCARD_VERSION ("addressbook_vcard_version") are not used anymore (now stored in ContactsContract.SyncState)
|
||||
- KEY_LAST_ANDROID_VERSION ("last_android_version") has been added
|
||||
*/
|
||||
|
||||
// move previous address book model to ContactsContract.SyncState
|
||||
val provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY) ?:
|
||||
throw ContactsStorageException("Couldn't access Contacts provider")
|
||||
|
||||
try {
|
||||
val addr = LocalAddressBook(context, account, provider)
|
||||
|
||||
// until now, ContactsContract.settings.UNGROUPED_VISIBLE was not set explicitly
|
||||
val values = ContentValues()
|
||||
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
|
||||
addr.settings = values
|
||||
|
||||
val url = accountManager.getUserData(account, "addressbook_url")
|
||||
if (!url.isNullOrEmpty())
|
||||
addr.url = url
|
||||
accountManager.setUserData(account, "addressbook_url", null)
|
||||
|
||||
val cTag = accountManager.getUserData (account, "addressbook_ctag")
|
||||
if (!cTag.isNullOrEmpty())
|
||||
addr.lastSyncState = SyncState(SyncState.Type.CTAG, cTag)
|
||||
accountManager.setUserData(account, "addressbook_ctag", null)
|
||||
} finally {
|
||||
provider.closeCompat()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.content.Context
|
||||
|
||||
open class DefaultsProvider(
|
||||
private val allowOverride: Boolean = true
|
||||
): SettingsProvider {
|
||||
|
||||
open val booleanDefaults = mapOf(
|
||||
Pair(Settings.DISTRUST_SYSTEM_CERTIFICATES, Settings.DISTRUST_SYSTEM_CERTIFICATES_DEFAULT),
|
||||
Pair(Settings.OVERRIDE_PROXY, Settings.OVERRIDE_PROXY_DEFAULT)
|
||||
)
|
||||
|
||||
open val intDefaults = mapOf(
|
||||
Pair(Settings.OVERRIDE_PROXY_PORT, Settings.OVERRIDE_PROXY_PORT_DEFAULT)
|
||||
)
|
||||
|
||||
open val longDefaults = mapOf<String, Long>()
|
||||
|
||||
open val stringDefaults = mapOf(
|
||||
Pair(Settings.OVERRIDE_PROXY_HOST, Settings.OVERRIDE_PROXY_HOST_DEFAULT)
|
||||
)
|
||||
|
||||
override fun forceReload() {
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
}
|
||||
|
||||
|
||||
private fun hasKey(key: String) =
|
||||
booleanDefaults.containsKey(key) ||
|
||||
intDefaults.containsKey(key) ||
|
||||
longDefaults.containsKey(key) ||
|
||||
stringDefaults.containsKey(key)
|
||||
|
||||
override fun has(key: String): Pair<Boolean, Boolean> {
|
||||
val has = hasKey(key)
|
||||
return Pair(has, allowOverride || !has)
|
||||
}
|
||||
|
||||
|
||||
override fun getBoolean(key: String) =
|
||||
Pair(booleanDefaults[key], allowOverride || !booleanDefaults.containsKey(key))
|
||||
|
||||
override fun getInt(key: String) =
|
||||
Pair(intDefaults[key], allowOverride || !intDefaults.containsKey(key))
|
||||
|
||||
override fun getLong(key: String) =
|
||||
Pair(longDefaults[key], allowOverride || !longDefaults.containsKey(key))
|
||||
|
||||
override fun getString(key: String) =
|
||||
Pair(stringDefaults[key], allowOverride || !stringDefaults.containsKey(key))
|
||||
|
||||
|
||||
override fun isWritable(key: String) = Pair(false, allowOverride || !hasKey(key))
|
||||
|
||||
override fun putBoolean(key: String, value: Boolean?) = false
|
||||
override fun putInt(key: String, value: Int?) = false
|
||||
override fun putLong(key: String, value: Long?) = false
|
||||
override fun putString(key: String, value: String?) = false
|
||||
|
||||
override fun remove(key: String) = false
|
||||
|
||||
|
||||
class Factory : ISettingsProviderFactory {
|
||||
override fun getProviders(context: Context) = listOf(DefaultsProvider())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.content.Context
|
||||
|
||||
interface ISettingsProviderFactory {
|
||||
|
||||
fun getProviders(context: Context): List<SettingsProvider>
|
||||
|
||||
}
|
||||
187
app/src/main/java/at/bitfire/davdroid/settings/Settings.kt
Normal file
187
app/src/main/java/at/bitfire/davdroid/settings/Settings.kt
Normal file
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
class Settings(
|
||||
appContext: Context
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
// settings keys and default values
|
||||
const val DISTRUST_SYSTEM_CERTIFICATES = "distrust_system_certs"
|
||||
const val DISTRUST_SYSTEM_CERTIFICATES_DEFAULT = false
|
||||
const val OVERRIDE_PROXY = "override_proxy"
|
||||
const val OVERRIDE_PROXY_DEFAULT = false
|
||||
const val OVERRIDE_PROXY_HOST = "override_proxy_host"
|
||||
const val OVERRIDE_PROXY_PORT = "override_proxy_port"
|
||||
|
||||
const val OVERRIDE_PROXY_HOST_DEFAULT = "localhost"
|
||||
const val OVERRIDE_PROXY_PORT_DEFAULT = 8118
|
||||
|
||||
|
||||
private var singleton: Settings? = null
|
||||
|
||||
fun getInstance(context: Context): Settings {
|
||||
singleton?.let { return it }
|
||||
|
||||
val newInstance = Settings(context.applicationContext)
|
||||
singleton = newInstance
|
||||
return newInstance
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val providers = LinkedList<SettingsProvider>()
|
||||
private val observers = LinkedList<WeakReference<OnChangeListener>>()
|
||||
|
||||
init {
|
||||
val factories = ServiceLoader.load(ISettingsProviderFactory::class.java)
|
||||
Logger.log.fine("Loading settings providers from ${factories.count()} factories")
|
||||
factories.forEach { factory ->
|
||||
providers.addAll(factory.getProviders(appContext))
|
||||
}
|
||||
}
|
||||
|
||||
fun forceReload() {
|
||||
providers.forEach {
|
||||
it.forceReload()
|
||||
}
|
||||
onSettingsChanged()
|
||||
}
|
||||
|
||||
|
||||
/*** OBSERVERS ***/
|
||||
|
||||
fun addOnChangeListener(observer: OnChangeListener) {
|
||||
observers += WeakReference(observer)
|
||||
}
|
||||
|
||||
fun removeOnChangeListener(observer: OnChangeListener) {
|
||||
observers.removeAll { it.get() == null || it.get() == observer }
|
||||
}
|
||||
|
||||
fun onSettingsChanged() {
|
||||
observers.mapNotNull { it.get() }.forEach {
|
||||
it.onSettingsChanged()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*** SETTINGS ACCESS ***/
|
||||
|
||||
fun has(key: String): Boolean {
|
||||
Logger.log.fine("Looking for setting $key")
|
||||
var result = false
|
||||
for (provider in providers)
|
||||
try {
|
||||
val (value, further) = provider.has(key)
|
||||
Logger.log.finer("${provider::class.java.simpleName}: has $key = $value, continue: $further")
|
||||
if (value) {
|
||||
result = true
|
||||
break
|
||||
}
|
||||
if (!further)
|
||||
break
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't look up setting in $provider", e)
|
||||
}
|
||||
Logger.log.fine("Looking for setting $key -> $result")
|
||||
return result
|
||||
}
|
||||
|
||||
private fun<T> getValue(key: String, reader: (SettingsProvider) -> Pair<T?, Boolean>): T? {
|
||||
Logger.log.fine("Looking up setting $key")
|
||||
var result: T? = null
|
||||
for (provider in providers)
|
||||
try {
|
||||
val (value, further) = reader(provider)
|
||||
Logger.log.finer("${provider::class.java.simpleName}: value = $value, continue: $further")
|
||||
value?.let { result = it }
|
||||
if (!further)
|
||||
break
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't read setting from $provider", e)
|
||||
}
|
||||
Logger.log.fine("Looked up setting $key -> $result")
|
||||
return result
|
||||
}
|
||||
|
||||
fun getBoolean(key: String) =
|
||||
getValue(key) { provider -> provider.getBoolean(key) }
|
||||
|
||||
fun getInt(key: String) =
|
||||
getValue(key) { provider -> provider.getInt(key) }
|
||||
|
||||
fun getLong(key: String) =
|
||||
getValue(key) { provider -> provider.getLong(key) }
|
||||
|
||||
fun getString(key: String) =
|
||||
getValue(key) { provider -> provider.getString(key) }
|
||||
|
||||
|
||||
fun isWritable(key: String): Boolean {
|
||||
for (provider in providers) {
|
||||
val (value, further) = provider.isWritable(key)
|
||||
if (value)
|
||||
return true
|
||||
if (!further)
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun<T> putValue(key: String, value: T?, writer: (SettingsProvider) -> Boolean): Boolean {
|
||||
Logger.log.fine("Trying to write setting $key = $value")
|
||||
for (provider in providers) {
|
||||
val (writable, further) = provider.isWritable(key)
|
||||
Logger.log.finer("${provider::class.java.simpleName}: writable = $writable, continue: $further")
|
||||
if (writable)
|
||||
return try {
|
||||
writer(provider)
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't write setting to $provider", e)
|
||||
false
|
||||
}
|
||||
if (!further)
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun putBoolean(key: String, value: Boolean?) =
|
||||
putValue(key, value) { provider -> provider.putBoolean(key, value) }
|
||||
|
||||
fun putInt(key: String, value: Int?) =
|
||||
putValue(key, value) { provider -> provider.putInt(key, value) }
|
||||
|
||||
fun putLong(key: String, value: Long?) =
|
||||
putValue(key, value) { provider -> provider.putLong(key, value) }
|
||||
|
||||
fun putString(key: String, value: String?) =
|
||||
putValue(key, value) { provider -> provider.putString(key, value) }
|
||||
|
||||
fun remove(key: String): Boolean {
|
||||
var deleted = false
|
||||
providers.forEach { deleted = deleted || it.remove(key) }
|
||||
return deleted
|
||||
}
|
||||
|
||||
|
||||
interface OnChangeListener {
|
||||
fun onSettingsChanged()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
interface SettingsProvider {
|
||||
|
||||
fun forceReload()
|
||||
fun close()
|
||||
|
||||
fun has(key: String): Pair<Boolean, Boolean>
|
||||
|
||||
fun getBoolean(key: String): Pair<Boolean?, Boolean>
|
||||
fun getInt(key: String): Pair<Int?, Boolean>
|
||||
fun getLong(key: String): Pair<Long?, Boolean>
|
||||
fun getString(key: String): Pair<String?, Boolean>
|
||||
|
||||
fun isWritable(key: String): Pair<Boolean, Boolean>
|
||||
|
||||
fun putBoolean(key: String, value: Boolean?): Boolean
|
||||
fun putInt(key: String, value: Int?): Boolean
|
||||
fun putLong(key: String, value: Long?): Boolean
|
||||
fun putString(key: String, value: String?): Boolean
|
||||
|
||||
fun remove(key: String): Boolean
|
||||
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import android.content.SharedPreferences
|
||||
import android.preference.PreferenceManager
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
|
||||
class SharedPreferencesProvider(
|
||||
val context: Context
|
||||
): SettingsProvider, SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
companion object {
|
||||
private const val META_VERSION = "version"
|
||||
private const val CURRENT_VERSION = 0
|
||||
}
|
||||
|
||||
private val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
|
||||
init {
|
||||
val meta = context.getSharedPreferences("meta", MODE_PRIVATE)
|
||||
val version = meta.getInt(META_VERSION, -1)
|
||||
if (version == -1) {
|
||||
// first call, check whether to migrate from SQLite database (DAVdroid <1.9)
|
||||
firstCall(context)
|
||||
meta.edit().putInt(META_VERSION, CURRENT_VERSION).apply()
|
||||
}
|
||||
|
||||
preferences.registerOnSharedPreferenceChangeListener(this)
|
||||
}
|
||||
|
||||
override fun forceReload() {
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
preferences.unregisterOnSharedPreferenceChangeListener(this)
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
Settings.getInstance(context).onSettingsChanged()
|
||||
}
|
||||
|
||||
|
||||
override fun has(key: String) =
|
||||
Pair(preferences.contains(key), true)
|
||||
|
||||
private fun<T> getValue(key: String, reader: (SharedPreferences) -> T): Pair<T?, Boolean> {
|
||||
if (preferences.contains(key))
|
||||
return Pair(
|
||||
try { reader(preferences) } catch(e: ClassCastException) { null },
|
||||
true)
|
||||
|
||||
return Pair(null, true)
|
||||
}
|
||||
|
||||
override fun getBoolean(key: String): Pair<Boolean?, Boolean> =
|
||||
getValue(key) { preferences -> preferences.getBoolean(key, /* will never be used: */ false) }
|
||||
|
||||
override fun getInt(key: String): Pair<Int?, Boolean> =
|
||||
getValue(key) { preferences -> preferences.getInt(key, /* will never be used: */ -1) }
|
||||
|
||||
override fun getLong(key: String): Pair<Long?, Boolean> =
|
||||
getValue(key) { preferences -> preferences.getLong(key, /* will never be used: */ -1) }
|
||||
|
||||
override fun getString(key: String): Pair<String?, Boolean> =
|
||||
getValue(key) { preferences -> preferences.getString(key, /* will never be used: */ null) }
|
||||
|
||||
|
||||
override fun isWritable(key: String) =
|
||||
Pair(true, true)
|
||||
|
||||
private fun<T> putValue(key: String, value: T?, writer: (SharedPreferences.Editor, T) -> Unit): Boolean {
|
||||
return if (value == null)
|
||||
remove(key)
|
||||
else {
|
||||
Logger.log.fine("Writing setting $key = $value")
|
||||
val edit = preferences.edit()
|
||||
writer(edit, value)
|
||||
edit.apply()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
override fun putBoolean(key: String, value: Boolean?) =
|
||||
putValue(key, value) { editor, v -> editor.putBoolean(key, v) }
|
||||
|
||||
override fun putInt(key: String, value: Int?) =
|
||||
putValue(key, value) { editor, v -> editor.putInt(key, v) }
|
||||
|
||||
override fun putLong(key: String, value: Long?) =
|
||||
putValue(key, value) { editor, v -> editor.putLong(key, v) }
|
||||
|
||||
override fun putString(key: String, value: String?) =
|
||||
putValue(key, value) { editor, v -> editor.putString(key, v) }
|
||||
|
||||
override fun remove(key: String): Boolean {
|
||||
Logger.log.fine("Removing setting $key")
|
||||
preferences.edit()
|
||||
.remove(key)
|
||||
.apply()
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
private fun firstCall(context: Context) {
|
||||
// remove possible artifacts from DAVdroid <1.9
|
||||
val edit = preferences.edit()
|
||||
edit.remove("override_proxy")
|
||||
edit.remove("proxy_host")
|
||||
edit.remove("proxy_port")
|
||||
edit.remove("log_to_external_storage")
|
||||
edit.apply()
|
||||
|
||||
// open ServiceDB to upgrade it and possibly migrate settings
|
||||
ServiceDB.OpenHelper(context).use { it.readableDatabase }
|
||||
}
|
||||
|
||||
|
||||
class Factory : ISettingsProviderFactory {
|
||||
override fun getProviders(context: Context) = listOf(SharedPreferencesProvider(context))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 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
|
||||
*/
|
||||
package at.bitfire.davdroid.syncadapter;
|
||||
|
||||
import android.accounts.AbstractAccountAuthenticator;
|
||||
import android.accounts.Account;
|
||||
import android.accounts.AccountAuthenticatorResponse;
|
||||
import android.accounts.AccountManager;
|
||||
import android.accounts.NetworkErrorException;
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
|
||||
import at.bitfire.davdroid.ui.setup.LoginActivity;
|
||||
|
||||
public class AccountAuthenticatorService extends Service {
|
||||
private static AccountAuthenticator accountAuthenticator;
|
||||
|
||||
private AccountAuthenticator getAuthenticator() {
|
||||
if (accountAuthenticator != null)
|
||||
return accountAuthenticator;
|
||||
return accountAuthenticator = new AccountAuthenticator(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
if (intent.getAction().equals(android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT))
|
||||
return getAuthenticator().getIBinder();
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
private static class AccountAuthenticator extends AbstractAccountAuthenticator {
|
||||
final Context context;
|
||||
|
||||
public AccountAuthenticator(Context context) {
|
||||
super(context);
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType,
|
||||
String[] requiredFeatures, Bundle options) throws NetworkErrorException {
|
||||
Intent intent = new Intent(context, LoginActivity.class);
|
||||
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putParcelable(AccountManager.KEY_INTENT, intent);
|
||||
return bundle;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options) throws NetworkErrorException {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAuthTokenLabel(String authTokenType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, String[] features) throws NetworkErrorException {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.*
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.database.DatabaseUtils
|
||||
import android.os.Bundle
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.ui.setup.LoginActivity
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
|
||||
/**
|
||||
* Account authenticator for the main DAVx5 account type.
|
||||
*
|
||||
* Gets started when a DAVx5 account is removed, too, so it also watches for account removals
|
||||
* and contains the corresponding cleanup code.
|
||||
*/
|
||||
class AccountAuthenticatorService: Service(), OnAccountsUpdateListener {
|
||||
|
||||
companion object {
|
||||
|
||||
fun cleanupAccounts(context: Context) {
|
||||
Logger.log.info("Cleaning up orphaned accounts")
|
||||
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.writableDatabase
|
||||
|
||||
val sqlAccountNames = LinkedList<String>()
|
||||
val accountNames = HashSet<String>()
|
||||
val accountManager = AccountManager.get(context)
|
||||
for (account in accountManager.getAccountsByType(context.getString(R.string.account_type))) {
|
||||
sqlAccountNames.add(DatabaseUtils.sqlEscapeString(account.name))
|
||||
accountNames += account.name
|
||||
}
|
||||
|
||||
// delete orphaned address book accounts
|
||||
accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
|
||||
.map { LocalAddressBook(context, it, null) }
|
||||
.forEach {
|
||||
try {
|
||||
if (!accountNames.contains(it.mainAccount.name))
|
||||
it.delete()
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't delete address book account", e)
|
||||
}
|
||||
}
|
||||
|
||||
// delete orphaned services in DB
|
||||
if (sqlAccountNames.isEmpty())
|
||||
db.delete(ServiceDB.Services._TABLE, null, null)
|
||||
else
|
||||
db.delete(ServiceDB.Services._TABLE, "${ServiceDB.Services.ACCOUNT_NAME} NOT IN (${sqlAccountNames.joinToString(",")})", null)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
private lateinit var accountManager: AccountManager
|
||||
private lateinit var accountAuthenticator: AccountAuthenticator
|
||||
|
||||
override fun onCreate() {
|
||||
accountManager = AccountManager.get(this)
|
||||
accountManager.addOnAccountsUpdatedListener(this, null, true)
|
||||
|
||||
accountAuthenticator = AccountAuthenticator(this)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
accountManager.removeOnAccountsUpdatedListener(this)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?) =
|
||||
accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT }
|
||||
|
||||
|
||||
override fun onAccountsUpdated(accounts: Array<out Account>?) {
|
||||
cleanupAccounts(this)
|
||||
}
|
||||
|
||||
|
||||
private class AccountAuthenticator(
|
||||
val context: Context
|
||||
): AbstractAccountAuthenticator(context) {
|
||||
|
||||
override fun addAccount(response: AccountAuthenticatorResponse?, accountType: String?, authTokenType: String?, requiredFeatures: Array<String>?, options: Bundle?): Bundle {
|
||||
val intent = Intent(context, LoginActivity::class.java)
|
||||
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
|
||||
val bundle = Bundle(1)
|
||||
bundle.putParcelable(AccountManager.KEY_INTENT, intent)
|
||||
return bundle
|
||||
}
|
||||
|
||||
override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?) = null
|
||||
override fun getAuthTokenLabel(p0: String?) = null
|
||||
override fun confirmCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Bundle?) = null
|
||||
override fun updateCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
|
||||
override fun getAuthToken(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
|
||||
override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array<out String>?) = null
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
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
|
||||
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.database.DatabaseUtils
|
||||
import android.os.Bundle
|
||||
import android.provider.ContactsContract
|
||||
import androidx.core.content.ContextCompat
|
||||
import at.bitfire.davdroid.closeCompat
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.ui.AccountActivity
|
||||
import okhttp3.HttpUrl
|
||||
import java.util.logging.Level
|
||||
|
||||
class AddressBooksSyncAdapterService : SyncAdapterService() {
|
||||
|
||||
override fun syncAdapter() = AddressBooksSyncAdapter(this)
|
||||
|
||||
|
||||
class AddressBooksSyncAdapter(
|
||||
context: Context
|
||||
) : SyncAdapter(context) {
|
||||
|
||||
override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
try {
|
||||
val accountSettings = AccountSettings(context, account)
|
||||
|
||||
/* don't run sync if
|
||||
- sync conditions (e.g. "sync only in WiFi") are not met AND
|
||||
- this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions)
|
||||
*/
|
||||
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings))
|
||||
return
|
||||
|
||||
updateLocalAddressBooks(account, syncResult)
|
||||
|
||||
for (addressBookAccount in LocalAddressBook.findAll(context, null, account).map { it.account }) {
|
||||
Logger.log.log(Level.INFO, "Running sync for address book", addressBookAccount)
|
||||
val syncExtras = Bundle(extras)
|
||||
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_SETTINGS, true)
|
||||
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, true)
|
||||
ContentResolver.requestSync(addressBookAccount, ContactsContract.AUTHORITY, syncExtras)
|
||||
}
|
||||
} 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) {
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.readableDatabase
|
||||
|
||||
fun getService() =
|
||||
db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID),
|
||||
"${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
|
||||
arrayOf(account.name, ServiceDB.Services.SERVICE_CARDDAV), null, null, null)?.use { c ->
|
||||
if (c.moveToNext())
|
||||
c.getLong(0)
|
||||
else
|
||||
null
|
||||
}
|
||||
|
||||
fun remoteAddressBooks(service: Long?): MutableMap<HttpUrl, CollectionInfo> {
|
||||
val collections = mutableMapOf<HttpUrl, CollectionInfo>()
|
||||
service?.let {
|
||||
db.query(Collections._TABLE, null,
|
||||
Collections.SERVICE_ID + "=? AND " + Collections.SYNC, arrayOf(service.toString()), null, null, null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val values = ContentValues(cursor.columnCount)
|
||||
DatabaseUtils.cursorRowToContentValues(cursor, values)
|
||||
val info = CollectionInfo(values)
|
||||
collections[info.url] = info
|
||||
}
|
||||
}
|
||||
}
|
||||
return collections
|
||||
}
|
||||
|
||||
// enumerate remote and local address books
|
||||
val service = getService()
|
||||
val remote = remoteAddressBooks(service)
|
||||
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
|
||||
if (remote.isEmpty()) {
|
||||
Logger.log.info("No contacts permission, but no address book selected for synchronization")
|
||||
return
|
||||
} else {
|
||||
// no contacts permission, but address books should be synchronized -> show notification
|
||||
val intent = Intent(context, AccountActivity::class.java)
|
||||
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
notifyPermissions(intent)
|
||||
}
|
||||
}
|
||||
|
||||
val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
|
||||
try {
|
||||
if (contactsProvider == null) {
|
||||
Logger.log.severe("Couldn't access contacts provider")
|
||||
syncResult.databaseError = true
|
||||
return
|
||||
}
|
||||
|
||||
// delete/update local address books
|
||||
for (addressBook in LocalAddressBook.findAll(context, contactsProvider, account)) {
|
||||
val url = HttpUrl.parse(addressBook.url)!!
|
||||
val info = remote[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)
|
||||
} 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
|
||||
remote -= url
|
||||
}
|
||||
}
|
||||
|
||||
// create new local address books
|
||||
for ((_, info) in remote) {
|
||||
Logger.log.log(Level.INFO, "Adding local address book", info)
|
||||
LocalAddressBook.create(context, contactsProvider, account, info)
|
||||
}
|
||||
} finally {
|
||||
contactsProvider?.closeCompat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.content.Context;
|
||||
import android.content.SyncResult;
|
||||
import android.os.Bundle;
|
||||
|
||||
import org.apache.commons.codec.Charsets;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import at.bitfire.dav4android.DavCalendar;
|
||||
import at.bitfire.dav4android.DavResource;
|
||||
import at.bitfire.dav4android.exception.DavException;
|
||||
import at.bitfire.dav4android.exception.HttpException;
|
||||
import at.bitfire.dav4android.property.CalendarData;
|
||||
import at.bitfire.dav4android.property.GetCTag;
|
||||
import at.bitfire.dav4android.property.GetContentType;
|
||||
import at.bitfire.dav4android.property.GetETag;
|
||||
import at.bitfire.davdroid.AccountSettings;
|
||||
import at.bitfire.davdroid.App;
|
||||
import at.bitfire.davdroid.ArrayUtils;
|
||||
import at.bitfire.davdroid.Constants;
|
||||
import at.bitfire.davdroid.InvalidAccountException;
|
||||
import at.bitfire.davdroid.R;
|
||||
import at.bitfire.davdroid.resource.LocalCalendar;
|
||||
import at.bitfire.davdroid.resource.LocalEvent;
|
||||
import at.bitfire.davdroid.resource.LocalResource;
|
||||
import at.bitfire.ical4android.CalendarStorageException;
|
||||
import at.bitfire.ical4android.Event;
|
||||
import at.bitfire.ical4android.InvalidCalendarException;
|
||||
import at.bitfire.vcard4android.ContactsStorageException;
|
||||
import lombok.Cleanup;
|
||||
import okhttp3.HttpUrl;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.ResponseBody;
|
||||
|
||||
/**
|
||||
* Synchronization manager for CalDAV collections; handles events ({@code VEVENT}).
|
||||
*/
|
||||
public class CalendarSyncManager extends SyncManager {
|
||||
|
||||
protected static final int MAX_MULTIGET = 20;
|
||||
|
||||
|
||||
public CalendarSyncManager(Context context, Account account, AccountSettings settings, Bundle extras, String authority, SyncResult result, LocalCalendar calendar) throws InvalidAccountException {
|
||||
super(context, account, settings, extras, authority, result, "calendar/" + calendar.getId());
|
||||
localCollection = calendar;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int notificationId() {
|
||||
return Constants.NOTIFICATION_CALENDAR_SYNC;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getSyncErrorTitle() {
|
||||
return context.getString(R.string.sync_error_calendar, account.name);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
protected void prepare() {
|
||||
collectionURL = HttpUrl.parse(localCalendar().getName());
|
||||
davCollection = new DavCalendar(httpClient, collectionURL);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void queryCapabilities() throws DavException, IOException, HttpException {
|
||||
davCollection.propfind(0, GetCTag.NAME);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void prepareDirty() throws CalendarStorageException, ContactsStorageException {
|
||||
super.prepareDirty();
|
||||
|
||||
localCalendar().processDirtyExceptions();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected RequestBody prepareUpload(LocalResource resource) throws IOException, CalendarStorageException {
|
||||
LocalEvent local = (LocalEvent)resource;
|
||||
App.log.log(Level.FINE, "Preparing upload of event " + local.getFileName(), local.getEvent());
|
||||
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
local.getEvent().write(os);
|
||||
|
||||
return RequestBody.create(
|
||||
DavCalendar.MIME_ICALENDAR,
|
||||
os.toByteArray()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void listRemote() throws IOException, HttpException, DavException {
|
||||
// calculate time range limits
|
||||
Date limitStart = null;
|
||||
Integer pastDays = settings.getTimeRangePastDays();
|
||||
if (pastDays != null) {
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
calendar.add(Calendar.DAY_OF_MONTH, -pastDays);
|
||||
limitStart = calendar.getTime();
|
||||
}
|
||||
|
||||
// fetch list of remote VEVENTs and build hash table to index file name
|
||||
davCalendar().calendarQuery("VEVENT", limitStart, null);
|
||||
|
||||
remoteResources = new HashMap<>(davCollection.members.size());
|
||||
for (DavResource iCal : davCollection.members) {
|
||||
String fileName = iCal.fileName();
|
||||
App.log.fine("Found remote VEVENT: " + fileName);
|
||||
remoteResources.put(fileName, iCal);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void downloadRemote() throws IOException, HttpException, DavException, CalendarStorageException {
|
||||
App.log.info("Downloading " + toDownload.size() + " events (" + MAX_MULTIGET + " at once)");
|
||||
|
||||
// download new/updated iCalendars from server
|
||||
for (DavResource[] bunch : ArrayUtils.partition(toDownload.toArray(new DavResource[toDownload.size()]), MAX_MULTIGET)) {
|
||||
if (Thread.interrupted())
|
||||
return;
|
||||
App.log.info("Downloading " + StringUtils.join(bunch, ", "));
|
||||
|
||||
if (bunch.length == 1) {
|
||||
// only one contact, use GET
|
||||
DavResource remote = bunch[0];
|
||||
|
||||
ResponseBody body = remote.get("text/calendar");
|
||||
|
||||
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
|
||||
GetETag eTag = (GetETag)remote.properties.get(GetETag.NAME);
|
||||
if (eTag == null || StringUtils.isEmpty(eTag.eTag))
|
||||
throw new DavException("Received CalDAV GET response without ETag for " + remote.location);
|
||||
|
||||
Charset charset = Charsets.UTF_8;
|
||||
MediaType contentType = body.contentType();
|
||||
if (contentType != null)
|
||||
charset = contentType.charset(Charsets.UTF_8);
|
||||
|
||||
@Cleanup InputStream stream = body.byteStream();
|
||||
processVEvent(remote.fileName(), eTag.eTag, stream, charset);
|
||||
|
||||
} else {
|
||||
// multiple contacts, use multi-get
|
||||
List<HttpUrl> urls = new LinkedList<>();
|
||||
for (DavResource remote : bunch)
|
||||
urls.add(remote.location);
|
||||
davCalendar().multiget(urls.toArray(new HttpUrl[urls.size()]));
|
||||
|
||||
// process multiget results
|
||||
for (DavResource remote : davCollection.members) {
|
||||
String eTag;
|
||||
GetETag getETag = (GetETag)remote.properties.get(GetETag.NAME);
|
||||
if (getETag != null)
|
||||
eTag = getETag.eTag;
|
||||
else
|
||||
throw new DavException("Received multi-get response without ETag");
|
||||
|
||||
Charset charset = Charsets.UTF_8;
|
||||
GetContentType getContentType = (GetContentType)remote.properties.get(GetContentType.NAME);
|
||||
if (getContentType != null && getContentType.type != null) {
|
||||
MediaType type = MediaType.parse(getContentType.type);
|
||||
if (type != null)
|
||||
charset = type.charset(Charsets.UTF_8);
|
||||
}
|
||||
|
||||
CalendarData calendarData = (CalendarData)remote.properties.get(CalendarData.NAME);
|
||||
if (calendarData == null || calendarData.iCalendar == null)
|
||||
throw new DavException("Received multi-get response without address data");
|
||||
|
||||
@Cleanup InputStream stream = new ByteArrayInputStream(calendarData.iCalendar.getBytes());
|
||||
processVEvent(remote.fileName(), eTag, stream, charset);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private LocalCalendar localCalendar() { return ((LocalCalendar)localCollection); }
|
||||
private DavCalendar davCalendar() { return (DavCalendar)davCollection; }
|
||||
|
||||
private void processVEvent(String fileName, String eTag, InputStream stream, Charset charset) throws IOException, CalendarStorageException {
|
||||
Event[] events;
|
||||
try {
|
||||
events = Event.fromStream(stream, charset);
|
||||
} catch (InvalidCalendarException e) {
|
||||
App.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (events.length == 1) {
|
||||
Event newData = events[0];
|
||||
|
||||
// delete local event, if it exists
|
||||
LocalEvent localEvent = (LocalEvent)localResources.get(fileName);
|
||||
if (localEvent != null) {
|
||||
App.log.info("Updating " + fileName + " in local calendar");
|
||||
localEvent.setETag(eTag);
|
||||
localEvent.update(newData);
|
||||
syncResult.stats.numUpdates++;
|
||||
} else {
|
||||
App.log.info("Adding " + fileName + " to local calendar");
|
||||
localEvent = new LocalEvent(localCalendar(), newData, fileName, eTag);
|
||||
localEvent.add();
|
||||
syncResult.stats.numInserts++;
|
||||
}
|
||||
} else
|
||||
App.log.severe("Received VCALENDAR with not exactly one VEVENT with UID, but without RECURRENCE-ID; ignoring " + fileName);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import at.bitfire.dav4jvm.DavCalendar
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.DavResponseCallback
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.property.*
|
||||
import at.bitfire.davdroid.DavUtils
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
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.ical4android.Event
|
||||
import at.bitfire.ical4android.InvalidCalendarException
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.RequestBody
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.Reader
|
||||
import java.io.StringReader
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Synchronization manager for CalDAV collections; handles events (VEVENT)
|
||||
*/
|
||||
class CalendarSyncManager(
|
||||
context: Context,
|
||||
account: Account,
|
||||
accountSettings: AccountSettings,
|
||||
extras: Bundle,
|
||||
authority: String,
|
||||
syncResult: SyncResult,
|
||||
localCalendar: LocalCalendar
|
||||
): SyncManager<LocalEvent, LocalCalendar, DavCalendar>(context, account, accountSettings, extras, authority, syncResult, localCalendar) {
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
collectionURL = HttpUrl.parse(localCollection.name ?: return false) ?: return false
|
||||
davCollection = DavCalendar(httpClient.okHttpClient, collectionURL)
|
||||
|
||||
// if there are dirty exceptions for events, mark their master events as dirty, too
|
||||
localCollection.processDirtyExceptions()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun queryCapabilities(): SyncState? =
|
||||
useRemoteCollection {
|
||||
var syncState: SyncState? = null
|
||||
it.propfind(0, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF) {
|
||||
response[SupportedReportSet::class.java]?.let { supported ->
|
||||
hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION)
|
||||
}
|
||||
|
||||
syncState = syncState(response)
|
||||
}
|
||||
}
|
||||
|
||||
Logger.log.info("Server supports Collection Sync: $hasCollectionSync")
|
||||
syncState
|
||||
}
|
||||
|
||||
override fun syncAlgorithm() = if (accountSettings.getTimeRangePastDays() != null || !hasCollectionSync)
|
||||
SyncAlgorithm.PROPFIND_REPORT
|
||||
else
|
||||
SyncAlgorithm.COLLECTION_SYNC
|
||||
|
||||
override fun prepareUpload(resource: LocalEvent): RequestBody = useLocal(resource) {
|
||||
val event = requireNotNull(resource.event)
|
||||
Logger.log.log(Level.FINE, "Preparing upload of event ${resource.fileName}", event)
|
||||
|
||||
val os = ByteArrayOutputStream()
|
||||
event.write(os)
|
||||
|
||||
RequestBody.create(
|
||||
DavCalendar.MIME_ICALENDAR_UTF8,
|
||||
os.toByteArray()
|
||||
)
|
||||
}
|
||||
|
||||
override fun listAllRemote(callback: DavResponseCallback) {
|
||||
// calculate time range limits
|
||||
var limitStart: Date? = null
|
||||
accountSettings.getTimeRangePastDays()?.let { pastDays ->
|
||||
val calendar = Calendar.getInstance()
|
||||
calendar.add(Calendar.DAY_OF_MONTH, -pastDays)
|
||||
limitStart = calendar.time
|
||||
}
|
||||
|
||||
return useRemoteCollection { remote ->
|
||||
Logger.log.info("Querying events since $limitStart")
|
||||
remote.calendarQuery("VEVENT", limitStart, null, callback)
|
||||
}
|
||||
}
|
||||
|
||||
override fun downloadRemote(bunch: List<HttpUrl>) {
|
||||
Logger.log.info("Downloading ${bunch.size} iCalendars: $bunch")
|
||||
if (bunch.size == 1) {
|
||||
val remote = bunch.first()
|
||||
// only one contact, use GET
|
||||
useRemote(DavResource(httpClient.okHttpClient, remote)) { resource ->
|
||||
resource.get(DavCalendar.MIME_ICALENDAR.toString()) { response ->
|
||||
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
|
||||
val eTag = response.header("ETag")?.let { GetETag(it).eTag }
|
||||
?: throw DavException("Received CalDAV GET response without ETag")
|
||||
|
||||
response.body()!!.use {
|
||||
processVEvent(resource.fileName(), eTag, it.charStream())
|
||||
}
|
||||
}
|
||||
}
|
||||
} else
|
||||
// multiple iCalendars, use calendar-multi-get
|
||||
useRemoteCollection {
|
||||
it.multiget(bunch) { response, _ ->
|
||||
useRemote(response) {
|
||||
if (!response.isSuccess()) {
|
||||
Logger.log.warning("Received non-successful multiget response for ${response.href}")
|
||||
return@useRemote
|
||||
}
|
||||
|
||||
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 address data")
|
||||
|
||||
processVEvent(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(iCal))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun postProcess() {
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun processVEvent(fileName: String, eTag: String, reader: Reader) {
|
||||
val events: List<Event>
|
||||
try {
|
||||
events = Event.fromReader(reader)
|
||||
} catch (e: InvalidCalendarException) {
|
||||
Logger.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e)
|
||||
notifyInvalidResource(e, fileName)
|
||||
return
|
||||
}
|
||||
|
||||
if (events.size == 1) {
|
||||
val newData = events.first()
|
||||
|
||||
// delete local event, if it exists
|
||||
useLocal(localCollection.findByName(fileName)) { local ->
|
||||
if (local != null) {
|
||||
Logger.log.log(Level.INFO, "Updating $fileName in local calendar", newData)
|
||||
local.eTag = eTag
|
||||
local.update(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
} else {
|
||||
Logger.log.log(Level.INFO, "Adding $fileName to local calendar", newData)
|
||||
useLocal(LocalEvent(localCollection, newData, fileName, eTag, 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,148 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 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
|
||||
*/
|
||||
package at.bitfire.davdroid.syncadapter;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.content.AbstractThreadedSyncAdapter;
|
||||
import android.content.ContentProviderClient;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.content.SyncResult;
|
||||
import android.database.Cursor;
|
||||
import android.database.DatabaseUtils;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
import android.database.sqlite.SQLiteOpenHelper;
|
||||
import android.os.Bundle;
|
||||
import android.provider.CalendarContract;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import at.bitfire.davdroid.AccountSettings;
|
||||
import at.bitfire.davdroid.App;
|
||||
import at.bitfire.davdroid.InvalidAccountException;
|
||||
import at.bitfire.davdroid.model.CollectionInfo;
|
||||
import at.bitfire.davdroid.model.ServiceDB;
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections;
|
||||
import at.bitfire.davdroid.model.ServiceDB.Services;
|
||||
import at.bitfire.davdroid.resource.LocalCalendar;
|
||||
import at.bitfire.ical4android.CalendarStorageException;
|
||||
import lombok.Cleanup;
|
||||
|
||||
public class CalendarsSyncAdapterService extends SyncAdapterService {
|
||||
|
||||
@Override
|
||||
protected AbstractThreadedSyncAdapter syncAdapter() {
|
||||
return new SyncAdapter(this);
|
||||
}
|
||||
|
||||
|
||||
private static class SyncAdapter extends SyncAdapterService.SyncAdapter {
|
||||
|
||||
public SyncAdapter(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
|
||||
super.onPerformSync(account, extras, authority, provider, syncResult);
|
||||
|
||||
try {
|
||||
AccountSettings settings = new AccountSettings(getContext(), account);
|
||||
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
|
||||
return;
|
||||
|
||||
updateLocalCalendars(provider, account, settings);
|
||||
|
||||
for (LocalCalendar calendar : (LocalCalendar[])LocalCalendar.find(account, provider, LocalCalendar.Factory.INSTANCE, CalendarContract.Calendars.SYNC_EVENTS + "!=0", null)) {
|
||||
App.log.info("Synchronizing calendar #" + calendar.getId() + ", URL: " + calendar.getName());
|
||||
CalendarSyncManager syncManager = new CalendarSyncManager(getContext(), account, settings, extras, authority, syncResult, calendar);
|
||||
syncManager.performSync();
|
||||
}
|
||||
} catch (CalendarStorageException e) {
|
||||
App.log.log(Level.SEVERE, "Couldn't enumerate local calendars", e);
|
||||
syncResult.databaseError = true;
|
||||
} catch (InvalidAccountException e) {
|
||||
App.log.log(Level.SEVERE, "Couldn't get account settings", e);
|
||||
}
|
||||
|
||||
App.log.info("Calendar sync complete");
|
||||
}
|
||||
|
||||
private void updateLocalCalendars(ContentProviderClient provider, Account account, AccountSettings settings) throws CalendarStorageException {
|
||||
SQLiteOpenHelper dbHelper = new ServiceDB.OpenHelper(getContext());
|
||||
try {
|
||||
// enumerate remote and local calendars
|
||||
SQLiteDatabase db = dbHelper.getReadableDatabase();
|
||||
Long service = getService(db, account);
|
||||
Map<String, CollectionInfo> remote = remoteCalendars(db, service);
|
||||
|
||||
LocalCalendar[] local = (LocalCalendar[])LocalCalendar.find(account, provider, LocalCalendar.Factory.INSTANCE, null, null);
|
||||
|
||||
boolean updateColors = settings.getManageCalendarColors();
|
||||
|
||||
// delete obsolete local calendar
|
||||
for (LocalCalendar calendar : local) {
|
||||
String url = calendar.getName();
|
||||
if (!remote.containsKey(url)) {
|
||||
App.log.fine("Deleting obsolete local calendar " + url);
|
||||
calendar.delete();
|
||||
} else {
|
||||
// remote CollectionInfo found for this local collection, update data
|
||||
CollectionInfo info = remote.get(url);
|
||||
App.log.fine("Updating local calendar " + url + " with " + info);
|
||||
calendar.update(info, updateColors);
|
||||
// we already have a local calendar for this remote collection, don't take into consideration anymore
|
||||
remote.remove(url);
|
||||
}
|
||||
}
|
||||
|
||||
// create new local calendars
|
||||
for (String url : remote.keySet()) {
|
||||
CollectionInfo info = remote.get(url);
|
||||
App.log.info("Adding local calendar list " + info);
|
||||
LocalCalendar.create(account, provider, info);
|
||||
}
|
||||
} finally {
|
||||
dbHelper.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
Long getService(@NonNull SQLiteDatabase db, @NonNull Account account) {
|
||||
@Cleanup Cursor c = db.query(Services._TABLE, new String[] { Services.ID },
|
||||
Services.ACCOUNT_NAME + "=? AND " + Services.SERVICE + "=?", new String[] { account.name, Services.SERVICE_CALDAV }, null, null, null);
|
||||
if (c.moveToNext())
|
||||
return c.getLong(0);
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private Map<String, CollectionInfo> remoteCalendars(@NonNull SQLiteDatabase db, Long service) {
|
||||
Map<String, CollectionInfo> collections = new LinkedHashMap<>();
|
||||
if (service != null) {
|
||||
@Cleanup Cursor cursor = db.query(Collections._TABLE, null,
|
||||
Collections.SERVICE_ID + "=? AND " + Collections.SUPPORTS_VEVENT + "!=0 AND " + Collections.SYNC,
|
||||
new String[] { String.valueOf(service) }, null, null, null);
|
||||
while (cursor.moveToNext()) {
|
||||
ContentValues values = new ContentValues();
|
||||
DatabaseUtils.cursorRowToContentValues(cursor, values);
|
||||
CollectionInfo info = CollectionInfo.fromDB(values);
|
||||
collections.put(info.url, info);
|
||||
}
|
||||
}
|
||||
return collections;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.*
|
||||
import android.database.DatabaseUtils
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import at.bitfire.davdroid.resource.LocalCalendar
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import okhttp3.HttpUrl
|
||||
import java.util.logging.Level
|
||||
|
||||
class CalendarsSyncAdapterService: SyncAdapterService() {
|
||||
|
||||
override fun syncAdapter() = CalendarsSyncAdapter(this)
|
||||
|
||||
|
||||
class CalendarsSyncAdapter(
|
||||
context: Context
|
||||
): SyncAdapter(context) {
|
||||
|
||||
override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
try {
|
||||
val accountSettings = AccountSettings(context, account)
|
||||
|
||||
/* don't run sync if
|
||||
- sync conditions (e.g. "sync only in WiFi") are not met AND
|
||||
- this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions)
|
||||
*/
|
||||
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings))
|
||||
return
|
||||
|
||||
if (accountSettings.getEventColors())
|
||||
AndroidCalendar.insertColors(provider, account)
|
||||
else
|
||||
AndroidCalendar.removeColors(provider, account)
|
||||
|
||||
updateLocalCalendars(provider, account, accountSettings)
|
||||
|
||||
for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, "${CalendarContract.Calendars.SYNC_EVENTS}!=0", null)) {
|
||||
Logger.log.info("Synchronizing calendar #${calendar.id}, URL: ${calendar.name}")
|
||||
CalendarSyncManager(context, account, accountSettings, extras, authority, syncResult, calendar).use {
|
||||
it.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) {
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.readableDatabase
|
||||
|
||||
fun getService() =
|
||||
db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID),
|
||||
"${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
|
||||
arrayOf(account.name, ServiceDB.Services.SERVICE_CALDAV), null, null, null)?.use { c ->
|
||||
if (c.moveToNext())
|
||||
c.getLong(0)
|
||||
else
|
||||
null
|
||||
}
|
||||
|
||||
fun remoteCalendars(service: Long?): MutableMap<HttpUrl, CollectionInfo> {
|
||||
val collections = mutableMapOf<HttpUrl, CollectionInfo>()
|
||||
service?.let {
|
||||
db.query(Collections._TABLE, null,
|
||||
"${Collections.SERVICE_ID}=? AND ${Collections.SUPPORTS_VEVENT}!=0 AND ${Collections.SYNC}",
|
||||
arrayOf(service.toString()), null, null, null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val values = ContentValues(cursor.columnCount)
|
||||
DatabaseUtils.cursorRowToContentValues(cursor, values)
|
||||
val info = CollectionInfo(values)
|
||||
collections[info.url] = info
|
||||
}
|
||||
}
|
||||
}
|
||||
return collections
|
||||
}
|
||||
|
||||
// enumerate remote and local calendars
|
||||
val service = getService()
|
||||
val remote = remoteCalendars(service)
|
||||
|
||||
// delete/update local calendars
|
||||
val updateColors = settings.getManageCalendarColors()
|
||||
|
||||
for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null))
|
||||
calendar.name?.let {
|
||||
val url = HttpUrl.parse(it)!!
|
||||
val info = remote[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
|
||||
remote -= url
|
||||
}
|
||||
}
|
||||
|
||||
// create new local calendars
|
||||
for ((_, info) in remote) {
|
||||
Logger.log.log(Level.INFO, "Adding local calendar", info)
|
||||
LocalCalendar.create(account, provider, info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 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
|
||||
*/
|
||||
package at.bitfire.davdroid.syncadapter;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.content.AbstractThreadedSyncAdapter;
|
||||
import android.content.ContentProviderClient;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.content.SyncResult;
|
||||
import android.database.Cursor;
|
||||
import android.database.DatabaseUtils;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
import android.database.sqlite.SQLiteOpenHelper;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
|
||||
import java.util.logging.Level;
|
||||
|
||||
import at.bitfire.davdroid.AccountSettings;
|
||||
import at.bitfire.davdroid.App;
|
||||
import at.bitfire.davdroid.InvalidAccountException;
|
||||
import at.bitfire.davdroid.model.CollectionInfo;
|
||||
import at.bitfire.davdroid.model.ServiceDB;
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections;
|
||||
import lombok.Cleanup;
|
||||
|
||||
public class ContactsSyncAdapterService extends SyncAdapterService {
|
||||
|
||||
@Override
|
||||
protected AbstractThreadedSyncAdapter syncAdapter() {
|
||||
return new ContactsSyncAdapter(this);
|
||||
}
|
||||
|
||||
|
||||
private static class ContactsSyncAdapter extends SyncAdapter {
|
||||
|
||||
public ContactsSyncAdapter(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
|
||||
super.onPerformSync(account, extras, authority, provider, syncResult);
|
||||
|
||||
SQLiteOpenHelper dbHelper = new ServiceDB.OpenHelper(getContext());
|
||||
try {
|
||||
AccountSettings settings = new AccountSettings(getContext(), account);
|
||||
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
|
||||
return;
|
||||
|
||||
SQLiteDatabase db = dbHelper.getReadableDatabase();
|
||||
Long service = getService(db, account);
|
||||
if (service != null) {
|
||||
CollectionInfo remote = remoteAddressBook(db, service);
|
||||
if (remote != null)
|
||||
try {
|
||||
ContactsSyncManager syncManager = new ContactsSyncManager(getContext(), account, settings, extras, authority, provider, syncResult, remote);
|
||||
syncManager.performSync();
|
||||
} catch(InvalidAccountException e) {
|
||||
App.log.log(Level.SEVERE, "Couldn't get account settings", e);
|
||||
}
|
||||
else
|
||||
App.log.info("No address book collection selected for synchronization");
|
||||
} else
|
||||
App.log.info("No CardDAV service found in DB");
|
||||
} catch (InvalidAccountException e) {
|
||||
App.log.log(Level.SEVERE, "Couldn't get account settings", e);
|
||||
} finally {
|
||||
dbHelper.close();
|
||||
}
|
||||
|
||||
App.log.info("Address book sync complete");
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private Long getService(@NonNull SQLiteDatabase db, @NonNull Account account) {
|
||||
@Cleanup Cursor c = db.query(ServiceDB.Services._TABLE, new String[] { ServiceDB.Services.ID },
|
||||
ServiceDB.Services.ACCOUNT_NAME + "=? AND " + ServiceDB.Services.SERVICE + "=?", new String[] { account.name, ServiceDB.Services.SERVICE_CARDDAV }, null, null, null);
|
||||
if (c.moveToNext())
|
||||
return c.getLong(0);
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private CollectionInfo remoteAddressBook(@NonNull SQLiteDatabase db, long service) {
|
||||
@Cleanup Cursor c = db.query(Collections._TABLE, null,
|
||||
Collections.SERVICE_ID + "=? AND " + Collections.SYNC, new String[] { String.valueOf(service) }, null, null, null);
|
||||
if (c.moveToNext()) {
|
||||
ContentValues values = new ContentValues();
|
||||
DatabaseUtils.cursorRowToContentValues(c, values);
|
||||
return CollectionInfo.fromDB(values);
|
||||
} else
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
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.Bundle
|
||||
import android.provider.ContactsContract
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import java.util.logging.Level
|
||||
|
||||
class ContactsSyncAdapterService: SyncAdapterService() {
|
||||
|
||||
companion object {
|
||||
const val PREVIOUS_GROUP_METHOD = "previous_group_method"
|
||||
}
|
||||
|
||||
override fun syncAdapter() = ContactsSyncAdapter(this)
|
||||
|
||||
|
||||
class ContactsSyncAdapter(
|
||||
context: Context
|
||||
): SyncAdapter(context) {
|
||||
|
||||
override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
try {
|
||||
val addressBook = LocalAddressBook(context, account, provider)
|
||||
val accountSettings = AccountSettings(context, addressBook.mainAccount)
|
||||
|
||||
// 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.setUserData(account, PREVIOUS_GROUP_METHOD, groupMethod)
|
||||
|
||||
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings))
|
||||
return
|
||||
|
||||
Logger.log.info("Synchronizing address book: ${addressBook.url}")
|
||||
Logger.log.info("Taking settings from: ${addressBook.mainAccount}")
|
||||
|
||||
ContactsSyncManager(context, account, accountSettings, extras, authority, syncResult, provider, addressBook).use {
|
||||
it.performSync()
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't sync contacts", e)
|
||||
}
|
||||
Logger.log.info("Contacts sync complete")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,529 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.content.ContentProviderClient;
|
||||
import android.content.ContentProviderOperation;
|
||||
import android.content.ContentUris;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.content.SyncResult;
|
||||
import android.database.Cursor;
|
||||
import android.os.Bundle;
|
||||
import android.os.RemoteException;
|
||||
import android.provider.ContactsContract;
|
||||
import android.provider.ContactsContract.Groups;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import org.apache.commons.codec.Charsets;
|
||||
import org.apache.commons.collections4.SetUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import at.bitfire.dav4android.DavAddressBook;
|
||||
import at.bitfire.dav4android.DavResource;
|
||||
import at.bitfire.dav4android.exception.DavException;
|
||||
import at.bitfire.dav4android.exception.HttpException;
|
||||
import at.bitfire.dav4android.property.AddressData;
|
||||
import at.bitfire.dav4android.property.GetCTag;
|
||||
import at.bitfire.dav4android.property.GetContentType;
|
||||
import at.bitfire.dav4android.property.GetETag;
|
||||
import at.bitfire.dav4android.property.SupportedAddressData;
|
||||
import at.bitfire.davdroid.AccountSettings;
|
||||
import at.bitfire.davdroid.App;
|
||||
import at.bitfire.davdroid.ArrayUtils;
|
||||
import at.bitfire.davdroid.Constants;
|
||||
import at.bitfire.davdroid.HttpClient;
|
||||
import at.bitfire.davdroid.InvalidAccountException;
|
||||
import at.bitfire.davdroid.R;
|
||||
import at.bitfire.davdroid.model.CollectionInfo;
|
||||
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.ical4android.CalendarStorageException;
|
||||
import at.bitfire.vcard4android.BatchOperation;
|
||||
import at.bitfire.vcard4android.Contact;
|
||||
import at.bitfire.vcard4android.ContactsStorageException;
|
||||
import at.bitfire.vcard4android.GroupMethod;
|
||||
import ezvcard.VCardVersion;
|
||||
import ezvcard.util.IOUtils;
|
||||
import lombok.Cleanup;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import okhttp3.HttpUrl;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
|
||||
/**
|
||||
* <p>Synchronization manager for CardDAV collections; handles contacts and groups.</p>
|
||||
*
|
||||
* <p></p>Group handling differs according to the {@link #groupMethod}. There are two basic methods to
|
||||
* handle/manage groups:</p>
|
||||
* <ul>
|
||||
* <li>{@code 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 {@link #prepareDirty()}. Empty groups can be deleted without further processing,
|
||||
* which is done in {@link #postProcess()} because groups may become empty after downloading
|
||||
* updated remoted contacts.</li>
|
||||
* <li>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.
|
||||
* <ol>
|
||||
* <li>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
|
||||
* {@link android.provider.ContactsContract.CommonDataKinds.GroupMembership}. DAVdroid 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,
|
||||
* DAVdroid always mirrors all {@link android.provider.ContactsContract.CommonDataKinds.GroupMembership}
|
||||
* data rows in respective {@link 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 {@link #prepareDirty()}.</li>
|
||||
* <li>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
|
||||
* {@link LocalGroup#COLUMN_PENDING_MEMBERS}. In {@link #postProcess()},
|
||||
* these "pending memberships" are assigned to the actual contacs and then cleaned up.</li>
|
||||
* </ol>
|
||||
* </ul>
|
||||
*/
|
||||
public class ContactsSyncManager extends SyncManager {
|
||||
protected static final int MAX_MULTIGET = 10;
|
||||
|
||||
final private ContentProviderClient provider;
|
||||
final private CollectionInfo remote;
|
||||
|
||||
private boolean hasVCard4;
|
||||
private GroupMethod groupMethod;
|
||||
|
||||
|
||||
public ContactsSyncManager(Context context, Account account, AccountSettings settings, Bundle extras, String authority, ContentProviderClient provider, SyncResult result, CollectionInfo remote) throws InvalidAccountException {
|
||||
super(context, account, settings, extras, authority, result, "addressBook");
|
||||
this.provider = provider;
|
||||
this.remote = remote;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int notificationId() {
|
||||
return Constants.NOTIFICATION_CONTACTS_SYNC;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getSyncErrorTitle() {
|
||||
return context.getString(R.string.sync_error_contacts, account.name);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
protected void prepare() throws ContactsStorageException {
|
||||
// prepare local address book
|
||||
localCollection = new LocalAddressBook(account, provider);
|
||||
LocalAddressBook localAddressBook = localAddressBook();
|
||||
|
||||
String url = remote.url;
|
||||
String lastUrl = localAddressBook.getURL();
|
||||
if (!url.equals(lastUrl)) {
|
||||
App.log.info("Selected address book has changed from " + lastUrl + " to " + url + ", deleting all local contacts");
|
||||
localAddressBook.deleteAll();
|
||||
}
|
||||
|
||||
// set up Contacts Provider Settings
|
||||
ContentValues values = new ContentValues(2);
|
||||
values.put(ContactsContract.Settings.SHOULD_SYNC, 1);
|
||||
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1);
|
||||
localAddressBook.updateSettings(values);
|
||||
|
||||
collectionURL = HttpUrl.parse(url);
|
||||
davCollection = new DavAddressBook(httpClient, collectionURL);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void queryCapabilities() throws DavException, IOException, HttpException {
|
||||
// prepare remote address book
|
||||
davCollection.propfind(0, SupportedAddressData.NAME, GetCTag.NAME);
|
||||
SupportedAddressData supportedAddressData = (SupportedAddressData)davCollection.properties.get(SupportedAddressData.NAME);
|
||||
hasVCard4 = supportedAddressData != null && supportedAddressData.hasVCard4();
|
||||
App.log.info("Server advertises VCard/4 support: " + hasVCard4);
|
||||
|
||||
groupMethod = settings.getGroupMethod();
|
||||
App.log.info("Contact group method: " + groupMethod);
|
||||
|
||||
localAddressBook().includeGroups = groupMethod == GroupMethod.GROUP_VCARDS;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void prepareDirty() throws CalendarStorageException, ContactsStorageException {
|
||||
super.prepareDirty();
|
||||
|
||||
LocalAddressBook addressBook = localAddressBook();
|
||||
|
||||
if (groupMethod == GroupMethod.CATEGORIES) {
|
||||
/* groups memberships are represented as contact CATEGORIES */
|
||||
|
||||
// groups with DELETED=1: set all members to dirty, then remove group
|
||||
for (LocalGroup group : addressBook.getDeletedGroups()) {
|
||||
App.log.fine("Finally removing group " + group);
|
||||
// useless because Android deletes group memberships as soon as a group is set to DELETED:
|
||||
// group.markMembersDirty();
|
||||
group.delete();
|
||||
}
|
||||
|
||||
// groups with DIRTY=1: mark all memberships as dirty, then clean DIRTY flag of group
|
||||
for (LocalGroup group : addressBook.getDirtyGroups()) {
|
||||
App.log.fine("Marking members of modified group " + group + " as dirty");
|
||||
group.markMembersDirty();
|
||||
group.clearDirty(null);
|
||||
}
|
||||
} else {
|
||||
/* groups as separate VCards: there are group contacts and individual contacts */
|
||||
|
||||
// mark groups with changed members as dirty
|
||||
BatchOperation batch = new BatchOperation(addressBook.provider);
|
||||
for (LocalContact contact : addressBook.getDirtyContacts())
|
||||
try {
|
||||
App.log.fine("Looking for changed group memberships of contact " + contact.getFileName());
|
||||
Set<Long> cachedGroups = contact.getCachedGroupMemberships(),
|
||||
currentGroups = contact.getGroupMemberships();
|
||||
for (Long groupID : SetUtils.disjunction(cachedGroups, currentGroups)) {
|
||||
App.log.fine("Marking group as dirty: " + groupID);
|
||||
batch.enqueue(ContentProviderOperation
|
||||
.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)))
|
||||
.withValue(Groups.DIRTY, 1)
|
||||
.withYieldAllowed(true)
|
||||
.build()
|
||||
);
|
||||
}
|
||||
} catch(FileNotFoundException ignored) {
|
||||
}
|
||||
batch.commit();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected RequestBody prepareUpload(@NonNull LocalResource resource) throws IOException, ContactsStorageException {
|
||||
final Contact contact;
|
||||
if (resource instanceof LocalContact) {
|
||||
LocalContact local = ((LocalContact)resource);
|
||||
contact = local.getContact();
|
||||
|
||||
if (groupMethod == GroupMethod.CATEGORIES) {
|
||||
// add groups as CATEGORIES
|
||||
for (long groupID : local.getGroupMemberships()) {
|
||||
try {
|
||||
@Cleanup Cursor c = provider.query(
|
||||
localAddressBook().syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)),
|
||||
new String[] { Groups.TITLE },
|
||||
null, null,
|
||||
null
|
||||
);
|
||||
if (c != null && c.moveToNext()) {
|
||||
String title = c.getString(0);
|
||||
if (!TextUtils.isEmpty(title))
|
||||
contact.categories.add(title);
|
||||
}
|
||||
} catch(RemoteException e) {
|
||||
throw new ContactsStorageException("Couldn't find group for adding CATEGORIES", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (resource instanceof LocalGroup)
|
||||
contact = ((LocalGroup)resource).getContact();
|
||||
else
|
||||
throw new IllegalArgumentException("Argument must be LocalContact or LocalGroup");
|
||||
|
||||
App.log.log(Level.FINE, "Preparing upload of VCard " + resource.getFileName(), contact);
|
||||
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
contact.write(hasVCard4 ? VCardVersion.V4_0 : VCardVersion.V3_0, groupMethod, settings.getVCardRFC6868(), os);
|
||||
|
||||
return RequestBody.create(
|
||||
hasVCard4 ? DavAddressBook.MIME_VCARD4 : DavAddressBook.MIME_VCARD3_UTF8,
|
||||
os.toByteArray()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void listRemote() throws IOException, HttpException, DavException {
|
||||
// fetch list of remote VCards and build hash table to index file name
|
||||
|
||||
try {
|
||||
davAddressBook().addressbookQuery();
|
||||
} catch(HttpException e) {
|
||||
/* non-successful responses to CARDDAV:addressbook-query with empty filter, tested on 2015/10/21
|
||||
* fastmail.com 403 Forbidden (DAV:error CARDDAV:supported-filter)
|
||||
* mailbox.org (OpenXchange) 400 Bad Request
|
||||
* SOGo 207 Multi-status, but without entries http://www.sogo.nu/bugs/view.php?id=3370
|
||||
* Zimbra ZCS 500 Server Error https://bugzilla.zimbra.com/show_bug.cgi?id=101902
|
||||
*/
|
||||
if (e.status == 400 || e.status == 403 || e.status == 500 || e.status == 501) {
|
||||
App.log.log(Level.WARNING, "Server error on REPORT addressbook-query, falling back to PROPFIND", e);
|
||||
davAddressBook().propfind(1, GetETag.NAME);
|
||||
} else
|
||||
// no defined fallback, pass through exception
|
||||
throw e;
|
||||
}
|
||||
|
||||
remoteResources = new HashMap<>(davCollection.members.size());
|
||||
for (DavResource vCard : davCollection.members) {
|
||||
String fileName = vCard.fileName();
|
||||
App.log.fine("Found remote VCard: " + fileName);
|
||||
remoteResources.put(fileName, vCard);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void downloadRemote() throws IOException, HttpException, DavException, ContactsStorageException {
|
||||
App.log.info("Downloading " + toDownload.size() + " contacts (" + MAX_MULTIGET + " at once)");
|
||||
|
||||
// prepare downloader which may be used to download external resource like contact photos
|
||||
Contact.Downloader downloader = new ResourceDownloader(collectionURL);
|
||||
|
||||
// download new/updated VCards from server
|
||||
for (DavResource[] bunch : ArrayUtils.partition(toDownload.toArray(new DavResource[toDownload.size()]), MAX_MULTIGET)) {
|
||||
if (Thread.interrupted())
|
||||
return;
|
||||
|
||||
App.log.info("Downloading " + StringUtils.join(bunch, ", "));
|
||||
|
||||
if (bunch.length == 1) {
|
||||
// only one contact, use GET
|
||||
DavResource remote = bunch[0];
|
||||
|
||||
ResponseBody body = remote.get("text/vcard;version=4.0, text/vcard;charset=utf-8;q=0.8, text/vcard;q=0.5");
|
||||
|
||||
// CardDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc6352#section-6.3.2.3]
|
||||
GetETag eTag = (GetETag)remote.properties.get(GetETag.NAME);
|
||||
if (eTag == null || StringUtils.isEmpty(eTag.eTag))
|
||||
throw new DavException("Received CardDAV GET response without ETag for " + remote.location);
|
||||
|
||||
Charset charset = Charsets.UTF_8;
|
||||
MediaType contentType = body.contentType();
|
||||
if (contentType != null)
|
||||
charset = contentType.charset(Charsets.UTF_8);
|
||||
|
||||
@Cleanup InputStream stream = body.byteStream();
|
||||
processVCard(remote.fileName(), eTag.eTag, stream, charset, downloader);
|
||||
|
||||
} else {
|
||||
// multiple contacts, use multi-get
|
||||
List<HttpUrl> urls = new LinkedList<>();
|
||||
for (DavResource remote : bunch)
|
||||
urls.add(remote.location);
|
||||
davAddressBook().multiget(urls.toArray(new HttpUrl[urls.size()]), hasVCard4);
|
||||
|
||||
// process multiget results
|
||||
for (DavResource remote : davCollection.members) {
|
||||
String eTag;
|
||||
GetETag getETag = (GetETag)remote.properties.get(GetETag.NAME);
|
||||
if (getETag != null)
|
||||
eTag = getETag.eTag;
|
||||
else
|
||||
throw new DavException("Received multi-get response without ETag");
|
||||
|
||||
Charset charset = Charsets.UTF_8;
|
||||
GetContentType getContentType = (GetContentType)remote.properties.get(GetContentType.NAME);
|
||||
if (getContentType != null && getContentType.type != null) {
|
||||
MediaType type = MediaType.parse(getContentType.type);
|
||||
if (type != null)
|
||||
charset = type.charset(Charsets.UTF_8);
|
||||
}
|
||||
|
||||
AddressData addressData = (AddressData)remote.properties.get(AddressData.NAME);
|
||||
if (addressData == null || addressData.vCard == null)
|
||||
throw new DavException("Received multi-get response without address data");
|
||||
|
||||
@Cleanup InputStream stream = new ByteArrayInputStream(addressData.vCard.getBytes());
|
||||
processVCard(remote.fileName(), eTag, stream, charset, downloader);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void postProcess() throws CalendarStorageException, ContactsStorageException {
|
||||
if (groupMethod == GroupMethod.CATEGORIES) {
|
||||
/* VCard3 group handling: groups memberships are represented as contact CATEGORIES */
|
||||
|
||||
// remove empty groups
|
||||
App.log.info("Removing empty groups");
|
||||
localAddressBook().removeEmptyGroups();
|
||||
|
||||
} else {
|
||||
/* VCard4 group handling: there are group contacts and individual contacts */
|
||||
App.log.info("Assigning memberships of downloaded contact groups");
|
||||
LocalGroup.applyPendingMemberships(localAddressBook());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void saveSyncState() throws CalendarStorageException, ContactsStorageException {
|
||||
super.saveSyncState();
|
||||
((LocalAddressBook)localCollection).setURL(remote.url);
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private DavAddressBook davAddressBook() { return (DavAddressBook)davCollection; }
|
||||
private LocalAddressBook localAddressBook() { return (LocalAddressBook)localCollection; }
|
||||
|
||||
private void processVCard(String fileName, String eTag, InputStream stream, Charset charset, Contact.Downloader downloader) throws IOException, ContactsStorageException {
|
||||
App.log.info("Processing CardDAV resource " + fileName);
|
||||
Contact[] contacts = Contact.fromStream(stream, charset, downloader);
|
||||
if (contacts.length == 0) {
|
||||
App.log.warning("Received VCard without data, ignoring");
|
||||
return;
|
||||
} else if (contacts.length > 1)
|
||||
App.log.warning("Received multiple VCards, using first one");
|
||||
|
||||
final Contact newData = contacts[0];
|
||||
|
||||
if (groupMethod == GroupMethod.CATEGORIES && newData.group) {
|
||||
groupMethod = GroupMethod.GROUP_VCARDS;
|
||||
App.log.warning("Received group VCard although group method is CATEGORIES. Deleting all groups; new group method: " + groupMethod);
|
||||
localAddressBook().removeGroups();
|
||||
settings.setGroupMethod(groupMethod);
|
||||
}
|
||||
|
||||
// update local contact, if it exists
|
||||
LocalResource local = localResources.get(fileName);
|
||||
if (local != null) {
|
||||
App.log.log(Level.INFO, "Updating " + fileName + " in local address book", newData);
|
||||
|
||||
if (local instanceof LocalGroup && newData.group) {
|
||||
// update group
|
||||
LocalGroup group = (LocalGroup)local;
|
||||
group.eTag = eTag;
|
||||
group.updateFromServer(newData);
|
||||
syncResult.stats.numUpdates++;
|
||||
|
||||
} else if (local instanceof LocalContact && !newData.group) {
|
||||
// update contact
|
||||
LocalContact contact = (LocalContact)local;
|
||||
contact.eTag = eTag;
|
||||
contact.update(newData);
|
||||
syncResult.stats.numUpdates++;
|
||||
|
||||
} else {
|
||||
// group has become an individual contact or vice versa
|
||||
try {
|
||||
local.delete();
|
||||
local = null;
|
||||
} catch(CalendarStorageException e) {
|
||||
// CalendarStorageException is not used by LocalGroup and LocalContact
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (local == null) {
|
||||
if (newData.group) {
|
||||
App.log.log(Level.INFO, "Creating local group", newData);
|
||||
LocalGroup group = new LocalGroup(localAddressBook(), newData, fileName, eTag);
|
||||
group.create();
|
||||
|
||||
local = group;
|
||||
} else {
|
||||
App.log.log(Level.INFO, "Creating local contact", newData);
|
||||
LocalContact contact = new LocalContact(localAddressBook(), newData, fileName, eTag);
|
||||
contact.create();
|
||||
|
||||
local = contact;
|
||||
}
|
||||
syncResult.stats.numInserts++;
|
||||
}
|
||||
|
||||
if (groupMethod == GroupMethod.CATEGORIES && local instanceof LocalContact) {
|
||||
// VCard3: update group memberships from CATEGORIES
|
||||
LocalContact contact = (LocalContact)local;
|
||||
|
||||
BatchOperation batch = new BatchOperation(provider);
|
||||
contact.removeGroupMemberships(batch);
|
||||
|
||||
for (String category : contact.getContact().categories) {
|
||||
long groupID = localAddressBook().findOrCreateGroup(category);
|
||||
contact.addToGroup(batch, groupID);
|
||||
}
|
||||
|
||||
batch.commit();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// downloader helper class
|
||||
|
||||
@RequiredArgsConstructor
|
||||
private class ResourceDownloader implements Contact.Downloader {
|
||||
final HttpUrl baseUrl;
|
||||
|
||||
@Override
|
||||
public byte[] download(String url, String accepts) {
|
||||
HttpUrl httpUrl = HttpUrl.parse(url);
|
||||
|
||||
if (httpUrl == null) {
|
||||
App.log.log(Level.SEVERE, "Invalid external resource URL", url);
|
||||
return null;
|
||||
}
|
||||
|
||||
String host = httpUrl.host();
|
||||
if (host == null) {
|
||||
App.log.log(Level.SEVERE, "External resource URL doesn't specify a host name", url);
|
||||
return null;
|
||||
}
|
||||
|
||||
OkHttpClient resourceClient = HttpClient.create();
|
||||
|
||||
// authenticate only against a certain host, and only upon request
|
||||
resourceClient = HttpClient.addAuthentication(resourceClient, baseUrl.host(), settings.username(), settings.password());
|
||||
|
||||
// allow redirects
|
||||
resourceClient = resourceClient.newBuilder()
|
||||
.followRedirects(true)
|
||||
.build();
|
||||
|
||||
try {
|
||||
Response response = resourceClient.newCall(new Request.Builder()
|
||||
.get()
|
||||
.url(httpUrl)
|
||||
.build()).execute();
|
||||
|
||||
ResponseBody body = response.body();
|
||||
if (body != null) {
|
||||
@Cleanup InputStream stream = body.byteStream();
|
||||
if (response.isSuccessful() && stream != null) {
|
||||
return IOUtils.toByteArray(stream);
|
||||
} else
|
||||
App.log.severe("Couldn't download external resource");
|
||||
}
|
||||
} catch(IOException e) {
|
||||
App.log.log(Level.SEVERE, "Couldn't download external resource", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.*
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.ContactsContract.Groups
|
||||
import at.bitfire.dav4jvm.DavAddressBook
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.DavResponseCallback
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.property.*
|
||||
import at.bitfire.davdroid.DavUtils
|
||||
import at.bitfire.davdroid.HttpClient
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.davdroid.resource.*
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.vcard4android.BatchOperation
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import ezvcard.VCardVersion
|
||||
import ezvcard.io.CannotParseException
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import java.io.*
|
||||
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,
|
||||
extras: Bundle,
|
||||
authority: String,
|
||||
syncResult: SyncResult,
|
||||
val provider: ContentProviderClient,
|
||||
localAddressBook: LocalAddressBook
|
||||
): SyncManager<LocalAddress, LocalAddressBook, DavAddressBook>(context, account, accountSettings, extras, authority, syncResult, localAddressBook) {
|
||||
|
||||
companion object {
|
||||
infix fun <T> Set<T>.disjunct(other: Set<T>) = (this - other) union (other - this)
|
||||
}
|
||||
|
||||
private val readOnly = localAddressBook.readOnly
|
||||
|
||||
private var hasVCard4 = false
|
||||
private val groupMethod = accountSettings.getGroupMethod()
|
||||
|
||||
/**
|
||||
* 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.N && 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.containsKey(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 = HttpUrl.parse(localCollection.url) ?: return false
|
||||
davCollection = DavAddressBook(httpClient.okHttpClient, collectionURL)
|
||||
|
||||
resourceDownloader = ResourceDownloader(davCollection.location)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun queryCapabilities(): SyncState? {
|
||||
Logger.log.info("Contact group method: $groupMethod")
|
||||
// in case of GROUP_VCARDs, treat groups as contacts in the local address book
|
||||
localCollection.includeGroups = groupMethod == GroupMethod.GROUP_VCARDS
|
||||
|
||||
return useRemoteCollection {
|
||||
var syncState: SyncState? = null
|
||||
it.propfind(0, SupportedAddressData.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF) {
|
||||
response[SupportedAddressData::class.java]?.let { supported ->
|
||||
hasVCard4 = supported.hasVCard4()
|
||||
}
|
||||
|
||||
response[SupportedReportSet::class.java]?.let { supported ->
|
||||
hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION)
|
||||
}
|
||||
|
||||
syncState = syncState(response)
|
||||
}
|
||||
}
|
||||
|
||||
Logger.log.info("Server supports vCard/4: $hasVCard4")
|
||||
Logger.log.info("Server supports Collection Sync: $hasCollectionSync")
|
||||
|
||||
syncState
|
||||
}
|
||||
}
|
||||
|
||||
override fun syncAlgorithm() = if (hasCollectionSync)
|
||||
SyncAlgorithm.COLLECTION_SYNC
|
||||
else
|
||||
SyncAlgorithm.PROPFIND_REPORT
|
||||
|
||||
override fun processLocallyDeleted(): Boolean {
|
||||
if (readOnly) {
|
||||
for (group in localCollection.findDeletedGroups()) {
|
||||
Logger.log.warning("Restoring locally deleted group (read-only address book!)")
|
||||
useLocal(group) { it.resetDeleted() }
|
||||
}
|
||||
|
||||
for (contact in localCollection.findDeletedContacts()) {
|
||||
Logger.log.warning("Restoring locally deleted contact (read-only address book!)")
|
||||
useLocal(contact) { it.resetDeleted() }
|
||||
}
|
||||
|
||||
return false
|
||||
} else
|
||||
// mirror deletions to remote collection (DELETE)
|
||||
return super.processLocallyDeleted()
|
||||
}
|
||||
|
||||
override fun uploadDirty(): Boolean {
|
||||
if (readOnly) {
|
||||
for (group in localCollection.findDirtyGroups()) {
|
||||
Logger.log.warning("Resetting locally modified group to ETag=null (read-only address book!)")
|
||||
useLocal(group) { it.clearDirty(null) }
|
||||
}
|
||||
|
||||
for (contact in localCollection.findDirtyContacts()) {
|
||||
Logger.log.warning("Resetting locally modified contact to ETag=null (read-only address book!)")
|
||||
useLocal(contact) { it.clearDirty(null) }
|
||||
}
|
||||
|
||||
} else {
|
||||
if (groupMethod == GroupMethod.CATEGORIES) {
|
||||
/* groups memberships are represented as contact CATEGORIES */
|
||||
|
||||
// groups with DELETED=1: set all members to dirty, then remove group
|
||||
for (group in localCollection.findDeletedGroups()) {
|
||||
Logger.log.fine("Finally removing group $group")
|
||||
// useless because Android deletes group memberships as soon as a group is set to DELETED:
|
||||
// group.markMembersDirty()
|
||||
useLocal(group) { it.delete() }
|
||||
}
|
||||
|
||||
// groups with DIRTY=1: mark all memberships as dirty, then clean DIRTY flag of group
|
||||
for (group in localCollection.findDirtyGroups()) {
|
||||
Logger.log.fine("Marking members of modified group $group as dirty")
|
||||
useLocal(group) {
|
||||
it.markMembersDirty()
|
||||
it.clearDirty(null)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
/* groups as separate VCards: there are group contacts and individual contacts */
|
||||
|
||||
// mark groups with changed members as dirty
|
||||
val batch = BatchOperation(localCollection.provider!!)
|
||||
for (contact in localCollection.findDirtyContacts())
|
||||
try {
|
||||
Logger.log.fine("Looking for changed group memberships of contact ${contact.fileName}")
|
||||
val cachedGroups = contact.getCachedGroupMemberships()
|
||||
val currentGroups = contact.getGroupMemberships()
|
||||
for (groupID in cachedGroups disjunct currentGroups) {
|
||||
Logger.log.fine("Marking group as dirty: $groupID")
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newUpdate(localCollection.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)))
|
||||
.withValue(Groups.DIRTY, 1)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
}
|
||||
} catch(e: FileNotFoundException) {
|
||||
}
|
||||
batch.commit()
|
||||
}
|
||||
}
|
||||
|
||||
// generate UID/file name for newly created contacts
|
||||
return super.uploadDirty()
|
||||
}
|
||||
|
||||
override fun prepareUpload(resource: LocalAddress): RequestBody = useLocal(resource) {
|
||||
val contact: Contact
|
||||
if (resource is LocalContact) {
|
||||
contact = resource.contact!!
|
||||
|
||||
if (groupMethod == GroupMethod.CATEGORIES) {
|
||||
// add groups as CATEGORIES
|
||||
for (groupID in resource.getGroupMemberships()) {
|
||||
provider.query(
|
||||
localCollection.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)),
|
||||
arrayOf(Groups.TITLE), null, null, null
|
||||
)?.use { cursor ->
|
||||
if (cursor.moveToNext()) {
|
||||
val title = cursor.getString(0)
|
||||
if (!title.isNullOrEmpty())
|
||||
contact.categories.add(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (resource is LocalGroup)
|
||||
contact = resource.contact!!
|
||||
else
|
||||
throw IllegalArgumentException("resource must be LocalContact or LocalGroup")
|
||||
|
||||
Logger.log.log(Level.FINE, "Preparing upload of VCard ${resource.fileName}", contact)
|
||||
|
||||
val os = ByteArrayOutputStream()
|
||||
contact.write(if (hasVCard4) VCardVersion.V4_0 else VCardVersion.V3_0, groupMethod, os)
|
||||
|
||||
RequestBody.create(
|
||||
if (hasVCard4) DavAddressBook.MIME_VCARD4 else DavAddressBook.MIME_VCARD3_UTF8,
|
||||
os.toByteArray()
|
||||
)
|
||||
}
|
||||
|
||||
override fun listAllRemote(callback: DavResponseCallback) =
|
||||
useRemoteCollection {
|
||||
it.propfind(1, ResourceType.NAME, GetETag.NAME, callback = callback)
|
||||
}
|
||||
|
||||
override fun downloadRemote(bunch: List<HttpUrl>) {
|
||||
Logger.log.info("Downloading ${bunch.size} vCards: $bunch")
|
||||
if (bunch.size == 1) {
|
||||
val remote = bunch.first()
|
||||
// only one contact, use GET
|
||||
useRemote(DavResource(httpClient.okHttpClient, remote)) { resource ->
|
||||
resource.get("text/vcard;version=4.0, text/vcard;charset=utf-8;q=0.8, text/vcard;q=0.5") { response ->
|
||||
// CardDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc6352#section-6.3.2.3]
|
||||
val eTag = response.header("ETag")?.let { GetETag(it).eTag }
|
||||
?: throw DavException("Received CardDAV GET response without ETag")
|
||||
|
||||
response.body()!!.use {
|
||||
processVCard(resource.fileName(), eTag, it.charStream(), resourceDownloader)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else
|
||||
// multiple vCards, use addressbook-multi-get
|
||||
useRemoteCollection {
|
||||
it.multiget(bunch, hasVCard4) { response, _ ->
|
||||
useRemote(response) {
|
||||
if (!response.isSuccess()) {
|
||||
Logger.log.warning("Received non-successful multiget response for ${response.href}")
|
||||
return@useRemote
|
||||
}
|
||||
|
||||
val eTag = response[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
|
||||
val addressData = response[AddressData::class.java]
|
||||
val vCard = addressData?.vCard
|
||||
?: throw DavException("Received multi-get response without address data")
|
||||
|
||||
processVCard(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(vCard), resourceDownloader)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun postProcess() {
|
||||
if (groupMethod == GroupMethod.CATEGORIES) {
|
||||
/* VCard3 group handling: groups memberships are represented as contact CATEGORIES */
|
||||
|
||||
// remove empty groups
|
||||
Logger.log.info("Removing empty groups")
|
||||
localCollection.removeEmptyGroups()
|
||||
|
||||
} else {
|
||||
/* VCard4 group handling: there are group contacts and individual contacts */
|
||||
Logger.log.info("Assigning memberships of downloaded contact groups")
|
||||
LocalGroup.applyPendingMemberships(localCollection)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun processVCard(fileName: String, eTag: String, reader: Reader, downloader: Contact.Downloader) {
|
||||
Logger.log.info("Processing CardDAV resource $fileName")
|
||||
|
||||
val contacts = try {
|
||||
Contact.fromReader(reader, 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()
|
||||
|
||||
if (groupMethod == GroupMethod.CATEGORIES && newData.group) {
|
||||
Logger.log.warning("Received group vCard although group method is CATEGORIES. Saving as regular contact")
|
||||
newData.group = false
|
||||
}
|
||||
|
||||
// update local contact, if it exists
|
||||
useLocal(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
|
||||
local.delete()
|
||||
local = null
|
||||
}
|
||||
}
|
||||
|
||||
if (local == null) {
|
||||
if (newData.group) {
|
||||
Logger.log.log(Level.INFO, "Creating local group", newData)
|
||||
useLocal(LocalGroup(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { group ->
|
||||
group.add()
|
||||
local = group
|
||||
}
|
||||
} else {
|
||||
Logger.log.log(Level.INFO, "Creating local contact", newData)
|
||||
useLocal(LocalContact(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { contact ->
|
||||
contact.add()
|
||||
local = contact
|
||||
}
|
||||
}
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
|
||||
if (groupMethod == GroupMethod.CATEGORIES)
|
||||
(local as? LocalContact)?.let { localContact ->
|
||||
// VCard3: update group memberships from CATEGORIES
|
||||
val batch = BatchOperation(provider)
|
||||
Logger.log.log(Level.FINE, "Removing contact group memberships")
|
||||
localContact.removeGroupMemberships(batch)
|
||||
|
||||
for (category in localContact.contact!!.categories) {
|
||||
val groupID = localCollection.findOrCreateGroup(category)
|
||||
Logger.log.log(Level.FINE, "Adding membership in group $category ($groupID)")
|
||||
localContact.addToGroup(batch, groupID)
|
||||
}
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && 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 = HttpUrl.parse(url)
|
||||
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 builder = HttpClient.Builder(context, baseUrl.host(), accountSettings.credentials())
|
||||
|
||||
// allow redirects
|
||||
builder.followRedirects(true)
|
||||
|
||||
val client = builder.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)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.AbstractAccountAuthenticator
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountAuthenticatorResponse
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import at.bitfire.davdroid.ui.AccountsActivity
|
||||
|
||||
class NullAuthenticatorService: Service() {
|
||||
|
||||
private lateinit var accountAuthenticator: AccountAuthenticator
|
||||
|
||||
override fun onCreate() {
|
||||
accountAuthenticator = AccountAuthenticator(this)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?) =
|
||||
accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT }
|
||||
|
||||
|
||||
private class AccountAuthenticator(
|
||||
val context: Context
|
||||
): AbstractAccountAuthenticator(context) {
|
||||
|
||||
override fun addAccount(response: AccountAuthenticatorResponse?, accountType: String?, authTokenType: String?, requiredFeatures: Array<String>?, options: Bundle?): Bundle {
|
||||
val intent = Intent(context, AccountsActivity::class.java)
|
||||
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
|
||||
val bundle = Bundle(1)
|
||||
bundle.putParcelable(AccountManager.KEY_INTENT, intent)
|
||||
return bundle
|
||||
}
|
||||
|
||||
override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?) = null
|
||||
override fun getAuthTokenLabel(p0: String?) = null
|
||||
override fun confirmCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Bundle?) = null
|
||||
override fun updateCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
|
||||
override fun getAuthToken(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
|
||||
override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array<out String>?) = null
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2016 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.AbstractThreadedSyncAdapter;
|
||||
import android.content.ContentProviderClient;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SyncResult;
|
||||
import android.graphics.drawable.BitmapDrawable;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.NetworkInfo;
|
||||
import android.net.wifi.WifiInfo;
|
||||
import android.net.wifi.WifiManager;
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v4.app.NotificationCompat;
|
||||
|
||||
import java.util.logging.Level;
|
||||
|
||||
import at.bitfire.davdroid.AccountSettings;
|
||||
import at.bitfire.davdroid.App;
|
||||
import at.bitfire.davdroid.Constants;
|
||||
import at.bitfire.davdroid.R;
|
||||
import at.bitfire.davdroid.ui.PermissionsActivity;
|
||||
|
||||
public abstract class SyncAdapterService extends Service {
|
||||
|
||||
abstract protected AbstractThreadedSyncAdapter syncAdapter();
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return syncAdapter().getSyncAdapterBinder();
|
||||
}
|
||||
|
||||
|
||||
public static abstract class SyncAdapter extends AbstractThreadedSyncAdapter {
|
||||
|
||||
public SyncAdapter(Context context) {
|
||||
super(context, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
|
||||
App.log.info("Sync for " + authority + " has been initiated");
|
||||
|
||||
// required for dav4android (ServiceLoader)
|
||||
Thread.currentThread().setContextClassLoader(getContext().getClassLoader());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSecurityException(Account account, Bundle extras, String authority, SyncResult syncResult) {
|
||||
App.log.log(Level.WARNING, "Security exception when opening content provider for " + authority);
|
||||
syncResult.databaseError = true;
|
||||
|
||||
Intent intent = new Intent(getContext(), PermissionsActivity.class);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
||||
Notification notify = new NotificationCompat.Builder(getContext())
|
||||
.setSmallIcon(R.drawable.ic_error_light)
|
||||
.setLargeIcon(((BitmapDrawable)getContext().getResources().getDrawable(R.drawable.ic_launcher)).getBitmap())
|
||||
.setContentTitle(getContext().getString(R.string.sync_error_permissions))
|
||||
.setContentText(getContext().getString(R.string.sync_error_permissions_text))
|
||||
.setContentIntent(PendingIntent.getActivity(getContext(), 0, intent, PendingIntent.FLAG_CANCEL_CURRENT))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.setLocalOnly(true)
|
||||
.build();
|
||||
NotificationManager nm = (NotificationManager)getContext().getSystemService(NOTIFICATION_SERVICE);
|
||||
nm.notify(Constants.NOTIFICATION_PERMISSIONS, notify);
|
||||
}
|
||||
|
||||
protected boolean checkSyncConditions(@NonNull AccountSettings settings) {
|
||||
if (settings.getSyncWifiOnly()) {
|
||||
ConnectivityManager cm = (ConnectivityManager)getContext().getSystemService(CONNECTIVITY_SERVICE);
|
||||
NetworkInfo network = cm.getActiveNetworkInfo();
|
||||
if (network == null) {
|
||||
App.log.info("No network available, stopping");
|
||||
return false;
|
||||
}
|
||||
if (network.getType() != ConnectivityManager.TYPE_WIFI || !network.isConnected()) {
|
||||
App.log.info("Not on connected WiFi, stopping");
|
||||
return false;
|
||||
}
|
||||
|
||||
String onlySSID = settings.getSyncWifiOnlySSID();
|
||||
if (onlySSID != null) {
|
||||
onlySSID = "\"" + onlySSID + "\"";
|
||||
WifiManager wifi = (WifiManager)getContext().getSystemService(WIFI_SERVICE);
|
||||
WifiInfo info = wifi.getConnectionInfo();
|
||||
if (info == null || !onlySSID.equals(info.getSSID())) {
|
||||
App.log.info("Connected to wrong WiFi network (" + info.getSSID() + ", required: " + onlySSID + "), ignoring");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright © 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
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.ui.AccountActivity
|
||||
import at.bitfire.davdroid.ui.AccountSettingsActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
abstract class SyncAdapterService: Service() {
|
||||
|
||||
companion object {
|
||||
/** Keep a list of running syncs to block multiple calls at the same time,
|
||||
* like run by some devices. Weak references are used for the case that a thread
|
||||
* is terminated and the `finally` block which cleans up [runningSyncs] is not
|
||||
* executed. */
|
||||
private val runningSyncs = mutableListOf<WeakReference<Pair<String, Account>>>()
|
||||
}
|
||||
|
||||
protected abstract fun syncAdapter(): AbstractThreadedSyncAdapter
|
||||
|
||||
override fun onBind(intent: Intent?) = syncAdapter().syncAdapterBinder!!
|
||||
|
||||
|
||||
abstract class SyncAdapter(
|
||||
context: Context
|
||||
): AbstractThreadedSyncAdapter(context, false) {
|
||||
|
||||
abstract fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult)
|
||||
|
||||
override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
Logger.log.log(Level.INFO, "$authority sync of $account has been initiated", extras.keySet().joinToString(", "))
|
||||
|
||||
// prevent multiple syncs of the same authority to be run for the same account
|
||||
val currentSync = Pair(authority, account)
|
||||
synchronized(runningSyncs) {
|
||||
if (runningSyncs.any { it.get() == currentSync }) {
|
||||
Logger.log.warning("There's already another $authority sync running for $account, aborting")
|
||||
return
|
||||
}
|
||||
runningSyncs += WeakReference(currentSync)
|
||||
}
|
||||
|
||||
try {
|
||||
// required for dav4jvm (ServiceLoader)
|
||||
Thread.currentThread().contextClassLoader = context.classLoader
|
||||
|
||||
SyncManager.cancelNotifications(NotificationManagerCompat.from(context), authority, account)
|
||||
sync(account, extras, authority, provider, syncResult)
|
||||
} finally {
|
||||
synchronized(runningSyncs) {
|
||||
runningSyncs.removeAll { it.get() == null || it.get() == currentSync }
|
||||
}
|
||||
}
|
||||
|
||||
Logger.log.info("Sync for $currentSync finished")
|
||||
}
|
||||
|
||||
override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
|
||||
Logger.log.log(Level.WARNING, "Security exception when opening content provider for $authority")
|
||||
syncResult.databaseError = true
|
||||
|
||||
val intent = Intent(context, AccountActivity::class.java)
|
||||
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
notifyPermissions(intent)
|
||||
}
|
||||
|
||||
protected fun checkSyncConditions(settings: AccountSettings): Boolean {
|
||||
if (settings.getSyncWifiOnly()) {
|
||||
val connectivityManager = context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val network = connectivityManager.activeNetworkInfo
|
||||
if (network == null || network.type != ConnectivityManager.TYPE_WIFI || !network.isConnected) {
|
||||
Logger.log.info("Not on connected WiFi, stopping")
|
||||
return false
|
||||
}
|
||||
|
||||
settings.getSyncWifiOnlySSIDs()?.let { onlySSIDs ->
|
||||
// getting the WiFi name requires location permission (and active location services) since Android 8.1
|
||||
// see https://issuetracker.google.com/issues/70633700
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
|
||||
val intent = Intent(context, AccountSettingsActivity::class.java)
|
||||
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, settings.account)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
notifyPermissions(intent)
|
||||
}
|
||||
|
||||
val wifi = context.applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
|
||||
val info = wifi.connectionInfo
|
||||
if (info == null || !onlySSIDs.contains(info.ssid.trim('"'))) {
|
||||
Logger.log.info("Connected to wrong WiFi network (${info.ssid}), ignoring")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
protected fun notifyPermissions(intent: Intent) {
|
||||
val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_ERRORS)
|
||||
.setSmallIcon(R.drawable.ic_sync_error_notification)
|
||||
.setContentTitle(context.getString(R.string.sync_error_permissions))
|
||||
.setContentText(context.getString(R.string.sync_error_permissions_text))
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
NotificationManagerCompat.from(context).notify(NotificationUtils.NOTIFY_PERMISSIONS, notify)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,448 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2013 – 2015 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
|
||||
*/
|
||||
package at.bitfire.davdroid.syncadapter;
|
||||
|
||||
import android.accounts.Account;
|
||||
import android.annotation.TargetApi;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SyncResult;
|
||||
import android.graphics.drawable.BitmapDrawable;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.support.v7.app.NotificationCompat;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import at.bitfire.dav4android.DavResource;
|
||||
import at.bitfire.dav4android.exception.ConflictException;
|
||||
import at.bitfire.dav4android.exception.DavException;
|
||||
import at.bitfire.dav4android.exception.HttpException;
|
||||
import at.bitfire.dav4android.exception.PreconditionFailedException;
|
||||
import at.bitfire.dav4android.exception.ServiceUnavailableException;
|
||||
import at.bitfire.dav4android.exception.UnauthorizedException;
|
||||
import at.bitfire.dav4android.property.GetCTag;
|
||||
import at.bitfire.dav4android.property.GetETag;
|
||||
import at.bitfire.davdroid.AccountSettings;
|
||||
import at.bitfire.davdroid.App;
|
||||
import at.bitfire.davdroid.HttpClient;
|
||||
import at.bitfire.davdroid.InvalidAccountException;
|
||||
import at.bitfire.davdroid.R;
|
||||
import at.bitfire.davdroid.resource.LocalCollection;
|
||||
import at.bitfire.davdroid.resource.LocalResource;
|
||||
import at.bitfire.davdroid.ui.AccountSettingsActivity;
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity;
|
||||
import at.bitfire.ical4android.CalendarStorageException;
|
||||
import at.bitfire.vcard4android.ContactsStorageException;
|
||||
import okhttp3.HttpUrl;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.RequestBody;
|
||||
|
||||
abstract public class SyncManager {
|
||||
|
||||
protected final int SYNC_PHASE_PREPARE = 0,
|
||||
SYNC_PHASE_QUERY_CAPABILITIES = 1,
|
||||
SYNC_PHASE_PROCESS_LOCALLY_DELETED = 2,
|
||||
SYNC_PHASE_PREPARE_DIRTY = 3,
|
||||
SYNC_PHASE_UPLOAD_DIRTY = 4,
|
||||
SYNC_PHASE_CHECK_SYNC_STATE = 5,
|
||||
SYNC_PHASE_LIST_LOCAL = 6,
|
||||
SYNC_PHASE_LIST_REMOTE = 7,
|
||||
SYNC_PHASE_COMPARE_LOCAL_REMOTE = 8,
|
||||
SYNC_PHASE_DOWNLOAD_REMOTE = 9,
|
||||
SYNC_PHASE_POST_PROCESSING = 10,
|
||||
SYNC_PHASE_SAVE_SYNC_STATE = 11;
|
||||
|
||||
protected final NotificationManager notificationManager;
|
||||
protected final String uniqueCollectionId;
|
||||
|
||||
protected final Context context;
|
||||
protected final Account account;
|
||||
protected final Bundle extras;
|
||||
protected final String authority;
|
||||
protected final SyncResult syncResult;
|
||||
|
||||
protected final AccountSettings settings;
|
||||
protected LocalCollection localCollection;
|
||||
|
||||
protected OkHttpClient httpClient;
|
||||
protected HttpUrl collectionURL;
|
||||
protected DavResource davCollection;
|
||||
|
||||
|
||||
/** remote CTag at the time of {@link #listRemote()} */
|
||||
protected String remoteCTag = null;
|
||||
|
||||
/** sync-able resources in the local collection, as enumerated by {@link #listLocal()} */
|
||||
protected Map<String, LocalResource> localResources;
|
||||
|
||||
/** sync-able resources in the remote collection, as enumerated by {@link #listRemote()} */
|
||||
protected Map<String, DavResource> remoteResources;
|
||||
|
||||
/** resources which have changed on the server, as determined by {@link #compareLocalRemote()} */
|
||||
protected Set<DavResource> toDownload;
|
||||
|
||||
|
||||
|
||||
public SyncManager(Context context, Account account, AccountSettings settings, Bundle extras, String authority, SyncResult syncResult, String uniqueCollectionId) throws InvalidAccountException {
|
||||
this.context = context;
|
||||
this.account = account;
|
||||
this.settings = settings;
|
||||
this.extras = extras;
|
||||
this.authority = authority;
|
||||
this.syncResult = syncResult;
|
||||
|
||||
// create HttpClient with given logger
|
||||
httpClient = HttpClient.create(context, account);
|
||||
|
||||
// dismiss previous error notifications
|
||||
this.uniqueCollectionId = uniqueCollectionId;
|
||||
notificationManager = (NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
notificationManager.cancel(uniqueCollectionId, notificationId());
|
||||
}
|
||||
|
||||
protected abstract int notificationId();
|
||||
protected abstract String getSyncErrorTitle();
|
||||
|
||||
@TargetApi(21)
|
||||
public void performSync() {
|
||||
int syncPhase = SYNC_PHASE_PREPARE;
|
||||
try {
|
||||
App.log.info("Preparing synchronization");
|
||||
prepare();
|
||||
|
||||
if (Thread.interrupted())
|
||||
return;
|
||||
syncPhase = SYNC_PHASE_QUERY_CAPABILITIES;
|
||||
App.log.info("Querying capabilities");
|
||||
queryCapabilities();
|
||||
|
||||
syncPhase = SYNC_PHASE_PROCESS_LOCALLY_DELETED;
|
||||
App.log.info("Processing locally deleted entries");
|
||||
processLocallyDeleted();
|
||||
|
||||
if (Thread.interrupted())
|
||||
return;
|
||||
syncPhase = SYNC_PHASE_PREPARE_DIRTY;
|
||||
App.log.info("Locally preparing dirty entries");
|
||||
prepareDirty();
|
||||
|
||||
syncPhase = SYNC_PHASE_UPLOAD_DIRTY;
|
||||
App.log.info("Uploading dirty entries");
|
||||
uploadDirty();
|
||||
|
||||
syncPhase = SYNC_PHASE_CHECK_SYNC_STATE;
|
||||
App.log.info("Checking sync state");
|
||||
if (checkSyncState()) {
|
||||
syncPhase = SYNC_PHASE_LIST_LOCAL;
|
||||
App.log.info("Listing local entries");
|
||||
listLocal();
|
||||
|
||||
if (Thread.interrupted())
|
||||
return;
|
||||
syncPhase = SYNC_PHASE_LIST_REMOTE;
|
||||
App.log.info("Listing remote entries");
|
||||
listRemote();
|
||||
|
||||
if (Thread.interrupted())
|
||||
return;
|
||||
syncPhase = SYNC_PHASE_COMPARE_LOCAL_REMOTE;
|
||||
App.log.info("Comparing local/remote entries");
|
||||
compareLocalRemote();
|
||||
|
||||
syncPhase = SYNC_PHASE_DOWNLOAD_REMOTE;
|
||||
App.log.info("Downloading remote entries");
|
||||
downloadRemote();
|
||||
|
||||
syncPhase = SYNC_PHASE_POST_PROCESSING;
|
||||
App.log.info("Post-processing");
|
||||
postProcess();
|
||||
|
||||
syncPhase = SYNC_PHASE_SAVE_SYNC_STATE;
|
||||
App.log.info("Saving sync state");
|
||||
saveSyncState();
|
||||
} else
|
||||
App.log.info("Remote collection didn't change, skipping remote sync");
|
||||
|
||||
} catch (IOException|ServiceUnavailableException e) {
|
||||
App.log.log(Level.WARNING, "I/O exception during sync, trying again later", e);
|
||||
syncResult.stats.numIoExceptions++;
|
||||
|
||||
if (e instanceof ServiceUnavailableException) {
|
||||
Date retryAfter = ((ServiceUnavailableException) e).retryAfter;
|
||||
if (retryAfter != null) {
|
||||
// how many seconds to wait? getTime() returns ms, so divide by 1000
|
||||
syncResult.delayUntil = (retryAfter.getTime() - new Date().getTime()) / 1000;
|
||||
}
|
||||
}
|
||||
|
||||
} catch(Exception|OutOfMemoryError e) {
|
||||
final int messageString;
|
||||
|
||||
if (e instanceof UnauthorizedException) {
|
||||
App.log.log(Level.SEVERE, "Not authorized anymore", e);
|
||||
messageString = R.string.sync_error_unauthorized;
|
||||
syncResult.stats.numAuthExceptions++;
|
||||
} else if (e instanceof HttpException || e instanceof DavException) {
|
||||
App.log.log(Level.SEVERE, "HTTP/DAV Exception during sync", e);
|
||||
messageString = R.string.sync_error_http_dav;
|
||||
syncResult.stats.numParseExceptions++;
|
||||
} else if (e instanceof CalendarStorageException || e instanceof ContactsStorageException) {
|
||||
App.log.log(Level.SEVERE, "Couldn't access local storage", e);
|
||||
messageString = R.string.sync_error_local_storage;
|
||||
syncResult.databaseError = true;
|
||||
} else {
|
||||
App.log.log(Level.SEVERE, "Unknown sync error", e);
|
||||
messageString = R.string.sync_error;
|
||||
syncResult.stats.numParseExceptions++;
|
||||
}
|
||||
|
||||
final Intent detailsIntent;
|
||||
if (e instanceof UnauthorizedException) {
|
||||
detailsIntent = new Intent(context, AccountSettingsActivity.class);
|
||||
detailsIntent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account);
|
||||
} else {
|
||||
detailsIntent = new Intent(context, DebugInfoActivity.class);
|
||||
detailsIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e);
|
||||
detailsIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account);
|
||||
detailsIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority);
|
||||
detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase);
|
||||
}
|
||||
|
||||
// to make the PendingIntent unique
|
||||
detailsIntent.setData(Uri.parse("uri://" + getClass().getName() + "/" + uniqueCollectionId));
|
||||
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(context);
|
||||
builder .setSmallIcon(R.drawable.ic_error_light)
|
||||
.setLargeIcon(((BitmapDrawable)context.getResources().getDrawable(R.drawable.ic_launcher)).getBitmap())
|
||||
.setContentTitle(getSyncErrorTitle())
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0, detailsIntent, PendingIntent.FLAG_CANCEL_CURRENT))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.setLocalOnly(true);
|
||||
|
||||
try {
|
||||
String[] phases = context.getResources().getStringArray(R.array.sync_error_phases);
|
||||
String message = context.getString(messageString, phases[syncPhase]);
|
||||
builder.setContentText(message);
|
||||
} catch (IndexOutOfBoundsException ex) {
|
||||
// should never happen
|
||||
}
|
||||
|
||||
notificationManager.notify(uniqueCollectionId, notificationId(), builder.build());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
abstract protected void prepare() throws ContactsStorageException;
|
||||
|
||||
abstract protected void queryCapabilities() throws IOException, HttpException, DavException, CalendarStorageException, ContactsStorageException;
|
||||
|
||||
/**
|
||||
* Process locally deleted entries (DELETE them on the server as well).
|
||||
* Checks Thread.interrupted() before each request to allow quick sync cancellation.
|
||||
*/
|
||||
protected void processLocallyDeleted() throws CalendarStorageException, ContactsStorageException {
|
||||
// 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.
|
||||
LocalResource[] localList = localCollection.getDeleted();
|
||||
for (LocalResource local : localList) {
|
||||
if (Thread.interrupted())
|
||||
return;
|
||||
|
||||
final String fileName = local.getFileName();
|
||||
if (!TextUtils.isEmpty(fileName)) {
|
||||
App.log.info(fileName + " has been deleted locally -> deleting from server");
|
||||
try {
|
||||
new DavResource(httpClient, collectionURL.newBuilder().addPathSegment(fileName).build())
|
||||
.delete(local.getETag());
|
||||
} catch (IOException|HttpException e) {
|
||||
App.log.warning("Couldn't delete " + fileName + " from server; ignoring (may be downloaded again)");
|
||||
}
|
||||
} else
|
||||
App.log.info("Removing local record #" + local.getId() + " which has been deleted locally and was never uploaded");
|
||||
local.delete();
|
||||
syncResult.stats.numDeletes++;
|
||||
}
|
||||
}
|
||||
|
||||
protected void prepareDirty() throws CalendarStorageException, ContactsStorageException {
|
||||
// assign file names and UIDs to new contacts so that we can use the file name as an index
|
||||
App.log.info("Looking for contacts/groups without file name");
|
||||
for (LocalResource local : localCollection.getWithoutFileName()) {
|
||||
String uuid = UUID.randomUUID().toString();
|
||||
App.log.fine("Found local record #" + local.getId() + " without file name; assigning file name/UID based on " + uuid);
|
||||
local.updateFileNameAndUID(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
abstract protected RequestBody prepareUpload(LocalResource resource) throws IOException, CalendarStorageException, ContactsStorageException;
|
||||
|
||||
/**
|
||||
* Uploads dirty records to the server, using a PUT request for each record.
|
||||
* Checks Thread.interrupted() before each request to allow quick sync cancellation.
|
||||
*/
|
||||
protected void uploadDirty() throws IOException, HttpException, CalendarStorageException, ContactsStorageException {
|
||||
// upload dirty contacts
|
||||
for (LocalResource local : localCollection.getDirty()) {
|
||||
if (Thread.interrupted())
|
||||
return;
|
||||
|
||||
final String fileName = local.getFileName();
|
||||
|
||||
DavResource remote = new DavResource(httpClient, collectionURL.newBuilder().addPathSegment(fileName).build());
|
||||
|
||||
// generate entity to upload (VCard, iCal, whatever)
|
||||
RequestBody body = prepareUpload(local);
|
||||
|
||||
try {
|
||||
if (local.getETag() == null) {
|
||||
App.log.info("Uploading new record " + fileName);
|
||||
remote.put(body, null, true);
|
||||
} else {
|
||||
App.log.info("Uploading locally modified record " + fileName);
|
||||
remote.put(body, local.getETag(), false);
|
||||
}
|
||||
} catch (ConflictException|PreconditionFailedException e) {
|
||||
// we can't interact with the user to resolve the conflict, so we treat 409 like 412
|
||||
App.log.log(Level.INFO, "Resource has been modified on the server before upload, ignoring", e);
|
||||
}
|
||||
|
||||
String eTag = null;
|
||||
GetETag newETag = (GetETag) remote.properties.get(GetETag.NAME);
|
||||
if (newETag != null) {
|
||||
eTag = newETag.eTag;
|
||||
App.log.fine("Received new ETag=" + eTag + " after uploading");
|
||||
} else
|
||||
App.log.fine("Didn't receive new ETag after uploading, setting to null");
|
||||
|
||||
local.clearDirty(eTag);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the current sync state (e.g. CTag) and whether synchronization from remote is required.
|
||||
* @return <ul>
|
||||
* <li><code>true</code> if the remote collection has changed, i.e. synchronization from remote is required</li>
|
||||
* <li><code>false</code> if the remote collection hasn't changed</li>
|
||||
* </ul>
|
||||
*/
|
||||
protected boolean checkSyncState() throws CalendarStorageException, ContactsStorageException {
|
||||
// check CTag (ignore on manual sync)
|
||||
GetCTag getCTag = (GetCTag)davCollection.properties.get(GetCTag.NAME);
|
||||
if (getCTag != null)
|
||||
remoteCTag = getCTag.cTag;
|
||||
|
||||
String localCTag = null;
|
||||
if (extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL))
|
||||
App.log.info("Manual sync, ignoring CTag");
|
||||
else
|
||||
localCTag = localCollection.getCTag();
|
||||
|
||||
if (remoteCTag != null && remoteCTag.equals(localCTag)) {
|
||||
App.log.info("Remote collection didn't change (CTag=" + remoteCTag + "), no need to query children");
|
||||
return false;
|
||||
} else
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all local resources which should be taken into account for synchronization into {@link #localResources}.
|
||||
*/
|
||||
protected void listLocal() throws CalendarStorageException, ContactsStorageException {
|
||||
// fetch list of local contacts and build hash table to index file name
|
||||
LocalResource[] localList = localCollection.getAll();
|
||||
localResources = new HashMap<>(localList.length);
|
||||
for (LocalResource resource : localList) {
|
||||
App.log.fine("Found local resource: " + resource.getFileName());
|
||||
localResources.put(resource.getFileName(), resource);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all members of the remote collection which should be taken into account for synchronization into {@link #remoteResources}.
|
||||
*/
|
||||
abstract protected void listRemote() throws IOException, HttpException, DavException;
|
||||
|
||||
/**
|
||||
* Compares {@link #localResources} and {@link #remoteResources} by file name and ETag:
|
||||
* <ul>
|
||||
* <li>Local resources which are not available in the remote collection (anymore) will be removed.</li>
|
||||
* <li>Resources whose remote ETag has changed will be added into {@link #toDownload}</li>
|
||||
* </ul>
|
||||
*/
|
||||
protected void compareLocalRemote() throws IOException, HttpException, DavException, CalendarStorageException, ContactsStorageException {
|
||||
/* check which contacts
|
||||
1. are not present anymore remotely -> delete immediately on local side
|
||||
2. updated remotely -> add to downloadNames
|
||||
3. added remotely -> add to downloadNames
|
||||
*/
|
||||
toDownload = new HashSet<>();
|
||||
for (String localName : localResources.keySet()) {
|
||||
DavResource remote = remoteResources.get(localName);
|
||||
if (remote == null) {
|
||||
App.log.info(localName + " is not on server anymore, deleting");
|
||||
localResources.get(localName).delete();
|
||||
syncResult.stats.numDeletes++;
|
||||
} else {
|
||||
// contact is still on server, check whether it has been updated remotely
|
||||
GetETag getETag = (GetETag)remote.properties.get(GetETag.NAME);
|
||||
if (getETag == null || getETag.eTag == null)
|
||||
throw new DavException("Server didn't provide ETag");
|
||||
String localETag = localResources.get(localName).getETag(),
|
||||
remoteETag = getETag.eTag;
|
||||
if (remoteETag.equals(localETag))
|
||||
syncResult.stats.numSkippedEntries++;
|
||||
else {
|
||||
App.log.info(localName + " has been changed on server (current ETag=" + remoteETag + ", last known ETag=" + localETag + ")");
|
||||
toDownload.add(remote);
|
||||
}
|
||||
|
||||
// remote entry has been seen, remove from list
|
||||
remoteResources.remove(localName);
|
||||
}
|
||||
}
|
||||
|
||||
// add all unseen (= remotely added) remote contacts
|
||||
if (!remoteResources.isEmpty()) {
|
||||
App.log.info("New resources have been found on the server: " + TextUtils.join(", ", remoteResources.keySet()));
|
||||
toDownload.addAll(remoteResources.values());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the remote resources in {@link #toDownload} and stores them locally.
|
||||
* Must check Thread.interrupted() periodically to allow quick sync cancellation.
|
||||
*/
|
||||
abstract protected void downloadRemote() throws IOException, HttpException, DavException, ContactsStorageException, CalendarStorageException;
|
||||
|
||||
/**
|
||||
* For post-processing of entries, for instance assigning groups.
|
||||
*/
|
||||
protected void postProcess() throws CalendarStorageException, ContactsStorageException {
|
||||
}
|
||||
|
||||
protected void saveSyncState() throws CalendarStorageException, ContactsStorageException {
|
||||
/* Save sync state (CTag). It doesn't matter if it has changed during the sync process
|
||||
(for instance, because another client has uploaded changes), because this will simply
|
||||
cause all remote entries to be listed at the next sync. */
|
||||
App.log.info("Saving CTag=" + remoteCTag);
|
||||
localCollection.setCTag(remoteCTag);
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user