mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2026-08-07 05:44:54 -04:00
Compare commits
711
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
770825677b | ||
|
|
b1b8651f9f | ||
|
|
2691a784e8 | ||
|
|
69cc0bdd25 | ||
|
|
f99ee40e01 | ||
|
|
e8262da7cb | ||
|
|
b0cee17707 | ||
|
|
fc24d0ff6c | ||
|
|
7cdc0ee2e9 | ||
|
|
a1a00d962e | ||
|
|
3b89410b3e | ||
|
|
f94d2f7ac3 | ||
|
|
a88a418feb | ||
|
|
3f1e58d513 | ||
|
|
2740a1bd3c | ||
|
|
03f8176b07 | ||
|
|
5c10a6aa2a | ||
|
|
2b1fd66da0 | ||
|
|
555de288a0 | ||
|
|
f467c0449b | ||
|
|
bedc1742c1 | ||
|
|
9ad5406712 | ||
|
|
6f536ba3a6 | ||
|
|
c12d569410 | ||
|
|
d04690fcd9 | ||
|
|
7b96af094d | ||
|
|
c6424ec3e6 | ||
|
|
f590571563 | ||
|
|
c5346325fb | ||
|
|
81c5bab3cb | ||
|
|
d5078c9d5c | ||
|
|
236fbdc936 | ||
|
|
4d96ad5c3e | ||
|
|
3aeadf3b67 | ||
|
|
a0a088724b | ||
|
|
a10c5b7656 | ||
|
|
b9b5a244b0 | ||
|
|
7d8ae2a2c4 | ||
|
|
017c472523 | ||
|
|
a502750e95 | ||
|
|
5f91c793af | ||
|
|
a564ddf79b | ||
|
|
bc6fb6b3aa | ||
|
|
b70c55fcf0 | ||
|
|
71c3221cb3 | ||
|
|
b7cb66296a | ||
|
|
be24e5db47 | ||
|
|
d44914700f | ||
|
|
7d2486d4ed | ||
|
|
4cc0968d9c | ||
|
|
3565428721 | ||
|
|
65be56fa57 | ||
|
|
d8ddad5b73 | ||
|
|
2ad3bc92ba | ||
|
|
a9a8cb981f | ||
|
|
bf0783e624 | ||
|
|
819e709613 | ||
|
|
14bd8c0bb8 | ||
|
|
51c7f8f5f2 | ||
|
|
714d63b78c | ||
|
|
eab494d3fa | ||
|
|
1c5e16b093 | ||
|
|
2bcd6e4686 | ||
|
|
029319c6c8 | ||
|
|
e07a55870c | ||
|
|
cd299ec83a | ||
|
|
d764a48a06 | ||
|
|
88b6dee6b0 | ||
|
|
131c633549 | ||
|
|
f3c9e406f9 | ||
|
|
9e23cdffb5 | ||
|
|
a776b65d66 | ||
|
|
76e83e1269 | ||
|
|
a1d6c69fad | ||
|
|
2248ba6508 | ||
|
|
d6ee73444e | ||
|
|
0a472905f7 | ||
|
|
2f7debb42f | ||
|
|
370367724c | ||
|
|
4dcde1140d | ||
|
|
64ad708316 | ||
|
|
d4e3864410 | ||
|
|
3b71808534 | ||
|
|
3132ffbd7f | ||
|
|
d553e1840c | ||
|
|
f368456ee6 | ||
|
|
6feb45eaa9 | ||
|
|
88baa19d86 | ||
|
|
de5903b375 | ||
|
|
ed0eefc971 | ||
|
|
33510ff203 | ||
|
|
05ded45711 | ||
|
|
3fab975a28 | ||
|
|
e767dcbf38 | ||
|
|
0d193bfba1 | ||
|
|
f1a4253c88 | ||
|
|
199d8b8eae | ||
|
|
41dae8bcb3 | ||
|
|
956698208a | ||
|
|
ea925e132b | ||
|
|
c87371ab1d | ||
|
|
666ddc972b | ||
|
|
9817594da1 | ||
|
|
dfec72ce6b | ||
|
|
46c5d7201d | ||
|
|
949f78f1ad | ||
|
|
675536f1d5 | ||
|
|
e5aedec9e1 | ||
|
|
aa13ccf35f | ||
|
|
cc25babc27 | ||
|
|
58fd2d02d7 | ||
|
|
353094d268 | ||
|
|
5e819e44a4 | ||
|
|
0080cf683c | ||
|
|
ba4e7a6499 | ||
|
|
526c0e2381 | ||
|
|
f7b31d219a | ||
|
|
47639c4511 | ||
|
|
6dd310637e | ||
|
|
e46e85c7b2 | ||
|
|
8f8e0f9ce8 | ||
|
|
5fd771e43d | ||
|
|
9b45a6b7c6 | ||
|
|
faaaa330a7 | ||
|
|
712467d933 | ||
|
|
cb0041bfed | ||
|
|
9ae61f6233 | ||
|
|
6045a988b4 | ||
|
|
c034789069 | ||
|
|
d98787a5bc | ||
|
|
3c1b9a7288 | ||
|
|
35cd11299d | ||
|
|
34ba52979c | ||
|
|
a14dfe5d39 | ||
|
|
232eaa1d6d | ||
|
|
d40af10b1f | ||
|
|
364d79535a | ||
|
|
d30077657c | ||
|
|
4b8c44fe5b | ||
|
|
88b5a26c3b | ||
|
|
d5807e44af | ||
|
|
c8ddec9651 | ||
|
|
0a70d91883 | ||
|
|
7a4fc19814 | ||
|
|
ef92753ac6 | ||
|
|
91d4e38227 | ||
|
|
ca38cbe935 | ||
|
|
29068389c7 | ||
|
|
d2df3c9027 | ||
|
|
d34efb103d | ||
|
|
2fd0ac2dd0 | ||
|
|
4f796669f6 | ||
|
|
fb7f974987 | ||
|
|
900f1fd857 | ||
|
|
e0a5748da3 | ||
|
|
c714785f47 | ||
|
|
fa17659ce8 | ||
|
|
8d4eeb763b | ||
|
|
3c1e9302df | ||
|
|
f2d5fe32c5 | ||
|
|
08639b2e89 | ||
|
|
5b50aa024e | ||
|
|
d8fb9c8373 | ||
|
|
b3dda2c112 | ||
|
|
ae8bf4d49e | ||
|
|
8e5ca5a72d | ||
|
|
2e78e1e746 | ||
|
|
fb4321f35f | ||
|
|
7d7e2584ed | ||
|
|
2b9dfc668f | ||
|
|
118d873cdf | ||
|
|
e7e30e9f2f | ||
|
|
520df3654d | ||
|
|
f0fe8e7620 | ||
|
|
783fead048 | ||
|
|
bd8470e7eb | ||
|
|
ec0e792f12 | ||
|
|
593d907e3a | ||
|
|
2c5b93ce43 | ||
|
|
68d48bb0f2 | ||
|
|
3acc05b605 | ||
|
|
3813c1e0c8 | ||
|
|
4e87186dd8 | ||
|
|
5e0a3e8b9f | ||
|
|
3bd96955cc | ||
|
|
3952181df2 | ||
|
|
054dba6ff8 | ||
|
|
68781fd96e | ||
|
|
00a400569a | ||
|
|
08b1886f9e | ||
|
|
d2d7ce8fe3 | ||
|
|
1354afebf7 | ||
|
|
08c8390202 | ||
|
|
d688eb5196 | ||
|
|
b757cba521 | ||
|
|
4fea7880e7 | ||
|
|
9555c066bd | ||
|
|
290ce7ad80 | ||
|
|
55ad06e328 | ||
|
|
19ab4a14ce | ||
|
|
ac940b3a12 | ||
|
|
7416c62c97 | ||
|
|
eb70f8690f | ||
|
|
bab84d7d0f | ||
|
|
ae145d897e | ||
|
|
d675fb961e | ||
|
|
967bc50256 | ||
|
|
2617063835 | ||
|
|
7fbdf96546 | ||
|
|
f6cb92dd6d | ||
|
|
b49bdda7e8 | ||
|
|
19b54748cd | ||
|
|
41ce609237 | ||
|
|
aafcc36c4d | ||
|
|
2496a3bf05 | ||
|
|
54e6426dc4 | ||
|
|
4542da7d89 | ||
|
|
977409511a | ||
|
|
ad8c832819 | ||
|
|
389af2b738 | ||
|
|
be2e15e463 | ||
|
|
c7c13520f9 | ||
|
|
317144630c | ||
|
|
34bc27fa79 | ||
|
|
210735a500 | ||
|
|
b30733c64b | ||
|
|
91234a688f | ||
|
|
5675e544b5 | ||
|
|
42a261b84e | ||
|
|
0d1825cbf3 | ||
|
|
9b8fc983cd | ||
|
|
3bde3758fc | ||
|
|
fd1f59d124 | ||
|
|
9886507b7d | ||
|
|
144643d6af | ||
|
|
14875f63ea | ||
|
|
28e567cf78 | ||
|
|
7997606550 | ||
|
|
fb0552de46 | ||
|
|
03c15a6924 | ||
|
|
c3b2929f88 | ||
|
|
eb2537a278 | ||
|
|
0b9727cca6 | ||
|
|
61231b4233 | ||
|
|
59252d7471 | ||
|
|
6ffa6fa9a7 | ||
|
|
03ee9a037b | ||
|
|
7ab13d648e | ||
|
|
25c54cce62 | ||
|
|
f0e45c71f5 | ||
|
|
fa528a64e9 | ||
|
|
c6aed90c96 | ||
|
|
2280f899ee | ||
|
|
a283cbbae5 | ||
|
|
bb95a25b91 | ||
|
|
f1ccd01708 | ||
|
|
c498225064 | ||
|
|
879b137cfc | ||
|
|
84379f7ee1 | ||
|
|
a594fd3d14 | ||
|
|
100b78a6a4 | ||
|
|
758711acb2 | ||
|
|
c90b6075db | ||
|
|
7109915e6e | ||
|
|
e8cf9fd5ab | ||
|
|
3a49815220 | ||
|
|
96881bd986 | ||
|
|
c08a0bdc43 | ||
|
|
773b2ee992 | ||
|
|
c2181c55d3 | ||
|
|
8449684dd2 | ||
|
|
28e7c91658 | ||
|
|
51867c5f3f | ||
|
|
1786b73ac6 | ||
|
|
1df3ddbe74 | ||
|
|
5ee8d76b34 | ||
|
|
5723225475 | ||
|
|
f73f6ca43c | ||
|
|
753c4b05a5 | ||
|
|
2e34fa686d | ||
|
|
a735564bc1 | ||
|
|
552f6b6936 | ||
|
|
50f7006e59 | ||
|
|
6ac5fe0204 | ||
|
|
19bfe5c5f2 | ||
|
|
212cd8ddb0 | ||
|
|
c30195d9ba | ||
|
|
3ca063416e | ||
|
|
940d622402 | ||
|
|
814abc60ed | ||
|
|
220ba4b151 | ||
|
|
777e124b54 | ||
|
|
f32493986b | ||
|
|
5025a61cd1 | ||
|
|
89a516bfd1 | ||
|
|
af71ed8bc5 | ||
|
|
fc29988dc6 | ||
|
|
77c947da14 | ||
|
|
ff901ce91f | ||
|
|
85a6b68a56 | ||
|
|
89050d88c6 | ||
|
|
ba0350c83d | ||
|
|
515969c4b8 | ||
|
|
9a8d29e774 | ||
|
|
2880b05b5d | ||
|
|
d6cff63f2d | ||
|
|
be6aa1b6a2 | ||
|
|
9ec4a4015d | ||
|
|
9dbc32d30b | ||
|
|
b63fc70cfb | ||
|
|
0142e63257 | ||
|
|
aaa7d71ae3 | ||
|
|
4adf3001ac | ||
|
|
5ccdafa074 | ||
|
|
fce2b85991 | ||
|
|
e5ebf10dc0 | ||
|
|
0f0acd62a3 | ||
|
|
2414b42867 | ||
|
|
243dac9952 | ||
|
|
12248b8bb9 | ||
|
|
d872bd06e5 | ||
|
|
065aa3fc84 | ||
|
|
20ee4e03f3 | ||
|
|
241e15404f | ||
|
|
4a00ba647d | ||
|
|
8d00814eaf | ||
|
|
c665744c31 | ||
|
|
2ef278c336 | ||
|
|
34de8431ae | ||
|
|
9d19d9757c | ||
|
|
81d13576e8 | ||
|
|
6f429328ef | ||
|
|
6465d83da4 | ||
|
|
0f5f39a9fe | ||
|
|
3e2459c85c | ||
|
|
8f52bf160e | ||
|
|
661276450c | ||
|
|
c93a89348e | ||
|
|
93464ccf8c | ||
|
|
3646a561c6 | ||
|
|
da9410c1b5 | ||
|
|
82f80fed1c | ||
|
|
94770fb0c8 | ||
|
|
9ddcec5624 | ||
|
|
4b5cb30762 | ||
|
|
58f05986c9 | ||
|
|
dd50f10c58 | ||
|
|
d3c1688407 | ||
|
|
80231dd44b | ||
|
|
4ecca76a95 | ||
|
|
410a04dc11 | ||
|
|
7fc01503d5 | ||
|
|
18542adb2c | ||
|
|
e34abf291e | ||
|
|
20bc5af4a3 | ||
|
|
f344bd3c28 | ||
|
|
419d732195 | ||
|
|
0c819c842b | ||
|
|
d348f54deb | ||
|
|
c2e9b27831 | ||
|
|
808958a69b | ||
|
|
bd77a5be63 | ||
|
|
ab34def8b0 | ||
|
|
d024cdb495 | ||
|
|
4f7f3b851a | ||
|
|
7f4b4855a0 | ||
|
|
bc2d1ba96d | ||
|
|
0bc1a8178a | ||
|
|
d0b928a93d | ||
|
|
b0163e16cd | ||
|
|
98899ab27b | ||
|
|
e4e1053f77 | ||
|
|
bcd2e8d4da | ||
|
|
f7700ba8aa | ||
|
|
a198309df5 | ||
|
|
c1a26fbbb7 | ||
|
|
5bf3aad575 | ||
|
|
97ae121331 | ||
|
|
31f5be01b4 | ||
|
|
d7fff8a760 | ||
|
|
faeb3b7dd0 | ||
|
|
fc1874af85 | ||
|
|
be80b6fde8 | ||
|
|
072c763dec | ||
|
|
6ad74c79f0 | ||
|
|
01d1b1a6c0 | ||
|
|
1c461e9d13 | ||
|
|
5ec4dbb9e7 | ||
|
|
3225a4bbc1 | ||
|
|
ece6be0f9d | ||
|
|
40c6643b41 | ||
|
|
b3afe48179 | ||
|
|
abf04e14d2 | ||
|
|
5b7947034a | ||
|
|
26d9f7284a | ||
|
|
7c1b787410 | ||
|
|
41bae221f0 | ||
|
|
243483a957 | ||
|
|
9d76d57af8 | ||
|
|
44bdd4d0ed | ||
|
|
40bffb78b0 | ||
|
|
5951414b25 | ||
|
|
dcd86c7d86 | ||
|
|
92966a5c57 | ||
|
|
ad733ebff1 | ||
|
|
59088086fd | ||
|
|
0b56d2a966 | ||
|
|
ed2a0419ad | ||
|
|
c6950b1c16 | ||
|
|
a796a1e9b3 | ||
|
|
c8cfbd6b07 | ||
|
|
654af1eec5 | ||
|
|
534953fe4c | ||
|
|
18c08bc9dd | ||
|
|
81d7813614 | ||
|
|
b7f0a9efa9 | ||
|
|
915ed7199b | ||
|
|
2665f6c4e6 | ||
|
|
13ec5a93ae | ||
|
|
a160d56643 | ||
|
|
c3f7c1b97e | ||
|
|
bc7e58232e | ||
|
|
f3e83922f7 | ||
|
|
af011a65db | ||
|
|
aa7e582bc9 | ||
|
|
03517584f2 | ||
|
|
5f3c6045d8 | ||
|
|
cd513683f5 | ||
|
|
011dd15c98 | ||
|
|
8fc86dd12c | ||
|
|
4e690a02ad | ||
|
|
a3ebd72321 | ||
|
|
87df8f880d | ||
|
|
97633c5204 | ||
|
|
33958ab548 | ||
|
|
f19d528739 | ||
|
|
365e04154b | ||
|
|
c707b1eb9d | ||
|
|
5e9fe92520 | ||
|
|
f1eabb6227 | ||
|
|
b5c99265c3 | ||
|
|
f738f74dea | ||
|
|
fb33767e57 | ||
|
|
8afc55dff3 | ||
|
|
9a63dd4693 | ||
|
|
2683012ec3 | ||
|
|
a79a59f409 | ||
|
|
81eabf5961 | ||
|
|
495cdf7c7e | ||
|
|
f6eee6c910 | ||
|
|
a405d07baf | ||
|
|
2696e64a83 | ||
|
|
6d6835c3b7 | ||
|
|
0eb6a56ef1 | ||
|
|
df335335d2 | ||
|
|
35011445e0 | ||
|
|
08789bbb2c | ||
|
|
001b445222 | ||
|
|
7d5ed0bd11 | ||
|
|
392e9f963e | ||
|
|
8d5f815be5 | ||
|
|
100aa665f5 | ||
|
|
caf7adb18a | ||
|
|
13b8c86ce3 | ||
|
|
a15185372d | ||
|
|
03d6dfef02 | ||
|
|
a021b973fe | ||
|
|
7e592d7647 | ||
|
|
38a4f3f53c | ||
|
|
986213dda5 | ||
|
|
d98a9d3673 | ||
|
|
12cb0ff8fe | ||
|
|
3a8f17cc2e | ||
|
|
9b52e51e4e | ||
|
|
be235c39b0 | ||
|
|
1acd2e1a55 | ||
|
|
23dba581e1 | ||
|
|
b29756aff2 | ||
|
|
4e2809910c | ||
|
|
9394c10d29 | ||
|
|
6d55de7c1d | ||
|
|
933f99b563 | ||
|
|
aeca582a7c | ||
|
|
6c998c31c3 | ||
|
|
1a796ade60 | ||
|
|
9e082d930b | ||
|
|
a7115ad39c | ||
|
|
4eb03f78f5 | ||
|
|
a6a7420935 | ||
|
|
3cba163c3b | ||
|
|
ca0ad612a7 | ||
|
|
897ede7582 | ||
|
|
6f5748c464 | ||
|
|
0423e00ffd | ||
|
|
ac46b8f45f | ||
|
|
142e229ff3 | ||
|
|
e65a1b5f16 | ||
|
|
bfbd4569ed | ||
|
|
a52e3507e7 | ||
|
|
49f7bbd8da | ||
|
|
8a75552a4c | ||
|
|
5ce03abad6 | ||
|
|
a7e8221e26 | ||
|
|
0cc23e3ecf | ||
|
|
661738c866 | ||
|
|
d56175652c | ||
|
|
760632302d | ||
|
|
5b21cb4938 | ||
|
|
03a64a43a3 | ||
|
|
1ad25a748a | ||
|
|
51ac34790c | ||
|
|
bc8d63f233 | ||
|
|
9452204f5c | ||
|
|
e98f0bd1ed | ||
|
|
dba35a5296 | ||
|
|
751e2eb5a4 | ||
|
|
4bf613c28b | ||
|
|
3e87f045d5 | ||
|
|
3f1740bc1c | ||
|
|
c8da282db0 | ||
|
|
6f0b9421c1 | ||
|
|
4b250dcb66 | ||
|
|
b6386c1b95 | ||
|
|
378214f62a | ||
|
|
c10fc6dfd1 | ||
|
|
cc1a1741f9 | ||
|
|
83eecc0c72 | ||
|
|
cfc71542f5 | ||
|
|
487509cb0c | ||
|
|
31f3fc80ef | ||
|
|
2e6a3efd25 | ||
|
|
2f5622edaf | ||
|
|
d73f4c5937 | ||
|
|
4c2e66d44c | ||
|
|
8d4c353d8c | ||
|
|
7bfcb2d8ad | ||
|
|
e3a7c7092e | ||
|
|
65376c2d42 | ||
|
|
58efd9ba03 | ||
|
|
fd4e5921ea | ||
|
|
6d9fc8734f | ||
|
|
7501f87d03 | ||
|
|
4205bf34fd | ||
|
|
25c1a1ad51 | ||
|
|
69c94c8d93 | ||
|
|
e9901f38f5 | ||
|
|
b8a728bdb9 | ||
|
|
1ec1db3045 | ||
|
|
b2bd53f36f | ||
|
|
3601678fe8 | ||
|
|
7cdf71fcdc | ||
|
|
97ec7ca721 | ||
|
|
9e49de1116 | ||
|
|
7e20bcd6f5 | ||
|
|
428c09c390 | ||
|
|
c1bd4e2156 | ||
|
|
0b5aa52642 | ||
|
|
ebe13cef5e | ||
|
|
5e5da95e23 | ||
|
|
07ec3c1a0a | ||
|
|
b8e6ff4627 | ||
|
|
732fe8151b | ||
|
|
aeaff0a0e6 | ||
|
|
7a3867b95c | ||
|
|
6b52d03002 | ||
|
|
a286b485e3 | ||
|
|
88972aed1d | ||
|
|
225ffc07cf | ||
|
|
1235a5e45a | ||
|
|
bdee53b5ab | ||
|
|
2c79ae20e5 | ||
|
|
75e5a59948 | ||
|
|
02d72e4f51 | ||
|
|
9a809c9761 | ||
|
|
d712238700 | ||
|
|
1cde020619 | ||
|
|
4be031e274 | ||
|
|
cf40cb2ebc | ||
|
|
1678873885 | ||
|
|
e78151a244 | ||
|
|
b7cd8baa05 | ||
|
|
3cab688782 | ||
|
|
7e7d36584c | ||
|
|
334dfb13d4 | ||
|
|
13cfe3ce98 | ||
|
|
3e4efe8b82 | ||
|
|
ec94fe61fd | ||
|
|
624f33c746 | ||
|
|
f69f449b44 | ||
|
|
0965c8d3a3 | ||
|
|
b8df91e12d | ||
|
|
f298d9dcb6 | ||
|
|
1e2051038c | ||
|
|
4c05dd1e45 | ||
|
|
65f7053c02 | ||
|
|
f92d114c01 | ||
|
|
475cd87720 | ||
|
|
75f00735d6 | ||
|
|
01dc0a1877 | ||
|
|
d22a2633af | ||
|
|
cba27426b7 | ||
|
|
c5905b44ed | ||
|
|
195eb19840 | ||
|
|
84211564bc | ||
|
|
a02b8a1b1e | ||
|
|
e2f154c963 | ||
|
|
e0b21abc35 | ||
|
|
ea94fbee72 | ||
|
|
e38feed08b | ||
|
|
e9a4a4f1ef | ||
|
|
b1ffdbfa95 | ||
|
|
8a02601cfc | ||
|
|
a12942c606 | ||
|
|
70973dcc0a | ||
|
|
a511b16211 | ||
|
|
46fb5df191 | ||
|
|
72e31a7330 | ||
|
|
2a6cfc5346 | ||
|
|
6c4acad758 | ||
|
|
c7fe069b1f | ||
|
|
972da39e4a | ||
|
|
c5a4958d3a | ||
|
|
a7aec7b4a9 | ||
|
|
ea31f5a621 | ||
|
|
ea06c4a7a1 | ||
|
|
fee6431981 | ||
|
|
9b992c54ca | ||
|
|
4073880185 | ||
|
|
b3f145f6a8 | ||
|
|
4f1488ece5 | ||
|
|
4dc65d0144 | ||
|
|
43cc5c0e7b | ||
|
|
983214d23b | ||
|
|
520ec21d9e | ||
|
|
e667de8992 | ||
|
|
8be6fdedd9 | ||
|
|
df012efe78 | ||
|
|
558075f1bb | ||
|
|
4505a5958d | ||
|
|
385e7b5e13 | ||
|
|
64068f7007 | ||
|
|
f4bb3639e6 | ||
|
|
8a651f135b | ||
|
|
6cfaad35b1 | ||
|
|
ec4fedf04e | ||
|
|
d30715f813 | ||
|
|
3af25754c4 | ||
|
|
15e8e20bad | ||
|
|
b35273a6d5 | ||
|
|
33b8aa8e05 | ||
|
|
eaa080cfe8 | ||
|
|
8c79c64d75 | ||
|
|
87b71b0b38 | ||
|
|
725c815e32 | ||
|
|
1986837be8 | ||
|
|
44cf628dd6 | ||
|
|
3cbfcbee9b | ||
|
|
8f7b48748b | ||
|
|
4f9bd5bebd | ||
|
|
0f09881eaf | ||
|
|
759e32b89b | ||
|
|
55d9dc4357 | ||
|
|
37ff088b78 | ||
|
|
af698c584b | ||
|
|
1fc2679bd4 | ||
|
|
66d6ec34d1 | ||
|
|
b802ed07d9 | ||
|
|
6889559fef | ||
|
|
8e8bc1c772 | ||
|
|
e7e7759f41 | ||
|
|
1e00ed217f | ||
|
|
f8f10be3ba | ||
|
|
5aa77e51a2 | ||
|
|
c1f57a4470 | ||
|
|
477a3c89f7 | ||
|
|
ab517bd87d | ||
|
|
c753dcaa8e | ||
|
|
362f0036be | ||
|
|
0626f1ecdc | ||
|
|
f85dbe934b | ||
|
|
603758fcbc | ||
|
|
fb671a67a4 | ||
|
|
ff4fe77fe3 | ||
|
|
da4443da62 | ||
|
|
a45aed6914 | ||
|
|
dbc89cb4fe | ||
|
|
b6611131db | ||
|
|
e3ba460991 | ||
|
|
68bd43fe6a | ||
|
|
b2ab24afd2 | ||
|
|
a01c531784 | ||
|
|
42929ddcf1 | ||
|
|
03a85da6e3 | ||
|
|
d15478b863 | ||
|
|
5db8bcb9d8 | ||
|
|
c87fb7bedd | ||
|
|
59ded55ac1 | ||
|
|
db1f2666e8 | ||
|
|
6ee010905e | ||
|
|
b9a13bf7de | ||
|
|
7ab740d443 | ||
|
|
9a9d9709c9 | ||
|
|
6727987051 | ||
|
|
5d3ef61b77 | ||
|
|
62ed38ee4f | ||
|
|
c012512de1 | ||
|
|
495b854bf2 | ||
|
|
3729951b52 | ||
|
|
350171c20c | ||
|
|
90cf92bc16 | ||
|
|
f92a3d77b5 |
No files matched your search
+4
-13
@@ -1,8 +1,10 @@
|
||||
image: registry.gitlab.com/bitfireat/davx5-ose:latest
|
||||
image: registry.gitlab.com/bitfireat/davdroid:latest
|
||||
|
||||
before_script:
|
||||
- git submodule update --init --recursive
|
||||
- export GRADLE_USER_HOME=`pwd`/.gradle; chmod +x gradlew
|
||||
- emulator64-x86 -avd test -no-audio -no-window & wait-for-emulator.sh
|
||||
- wget -q https://f-droid.org/repo/org.dmfs.tasks_481.apk && adb install org.dmfs.tasks_481.apk
|
||||
|
||||
cache:
|
||||
paths:
|
||||
@@ -10,20 +12,9 @@ cache:
|
||||
|
||||
test:
|
||||
script:
|
||||
- (cd /sdk/emulator; ./emulator @test -no-audio -no-window & wait-for-emulator.sh)
|
||||
- ./gradlew app:check app:connectedCheck
|
||||
- ./gradlew check mergeAndroidReports
|
||||
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
|
||||
+6
-6
@@ -1,12 +1,12 @@
|
||||
[submodule "dav4jvm"]
|
||||
path = dav4jvm
|
||||
url = https://gitlab.com/bitfireAT/dav4jvm.git
|
||||
[submodule "dav4android"]
|
||||
path = dav4android
|
||||
url = ../dav4android.git
|
||||
[submodule "ical4android"]
|
||||
path = ical4android
|
||||
url = https://gitlab.com/bitfireAT/ical4android.git
|
||||
url = ../ical4android.git
|
||||
[submodule "vcard4android"]
|
||||
path = vcard4android
|
||||
url = https://gitlab.com/bitfireAT/vcard4android.git
|
||||
url = ../vcard4android.git
|
||||
[submodule "cert4android"]
|
||||
path = cert4android
|
||||
url = https://gitlab.com/bitfireAT/cert4android.git
|
||||
url = ../cert4android.git
|
||||
@@ -1,37 +1,38 @@
|
||||

|
||||
|
||||
DAVx⁵
|
||||
[](https://gitlab.com/bitfireAT/davdroid/commits/master)
|
||||
|
||||
|
||||
DAVdroid
|
||||
========
|
||||
|
||||
Please see the [DAVx⁵ Web site](https://www.davx5.com) for
|
||||
comprehensive information about DAVx⁵.
|
||||
Please see the [DAVdroid Web site](https://davdroid.bitfire.at) for
|
||||
comprehensive information about DAVdroid.
|
||||
|
||||
DAVx⁵ is licensed under the [GPLv3 License](LICENSE).
|
||||
DAVdroid is licensed under the [GPLv3 License](LICENSE).
|
||||
|
||||
News and updates: [@davx5app](https://twitter.com/davx5app) on Twitter
|
||||
News and updates: [@davdroidapp](https://twitter.com/davdroidapp) on Twitter /
|
||||
[davdroid-announce](https://davdroid.bitfire.at/download/newsletter/) mailing list
|
||||
|
||||
Help, discussion, feature requests, bug reports and "issues": [DAVx⁵ forums](https://www.davx5.com/forums)
|
||||
Help and discussion: [DAVdroid forums](https://davdroid.bitfire.at/forums)
|
||||
|
||||
**If you want to support DAVx⁵, please consider [donating to DAVx⁵](https://www.davx5.com/donate)
|
||||
or [purchasing it](https://www.davx5.com/download).**
|
||||
**If you want to support DAVdroid, please consider [donating to DAVdroid](https://davdroid.bitfire.at/donate/)
|
||||
or [purchasing it](https://davdroid.bitfire.at/download/).**
|
||||
|
||||
Generated KDoc: https://bitfireAT.gitlab.io/davx5-ose/dokka/app/
|
||||
|
||||
Parts of DAVx⁵ have been outsourced into these libraries:
|
||||
Parts of DAVdroid 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
|
||||
* [dav4android](https://gitlab.com/bitfireAT/dav4android) – 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
|
||||
* [vcard4android](https://gitlab.com/bitfireAT/vcard4android) – VCard processing and Contacts Provider access
|
||||
|
||||
|
||||
USED THIRD-PARTY LIBRARIES
|
||||
==========================
|
||||
|
||||
Those libraries are used by DAVx⁵ (alphabetically):
|
||||
Those libraries are used by DAVdroid (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://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)
|
||||
* [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)
|
||||
* [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)
|
||||
+47
-63
@@ -9,41 +9,27 @@
|
||||
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 29
|
||||
buildToolsVersion '29.0.1'
|
||||
compileSdkVersion 26
|
||||
buildToolsVersion '26.0.1'
|
||||
|
||||
defaultConfig {
|
||||
applicationId "at.bitfire.davdroid"
|
||||
|
||||
versionCode 301
|
||||
minSdkVersion 16
|
||||
targetSdkVersion 25
|
||||
|
||||
versionCode 164
|
||||
|
||||
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 "2.5.5-beta1-ose"
|
||||
versionName "1.7.1-ose"
|
||||
buildConfigField "boolean", "customCerts", "true"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,8 +45,13 @@ android {
|
||||
|
||||
lintOptions {
|
||||
disable 'GoogleAppIndexingWarning' // we don't need Google indexing, thanks
|
||||
disable 'ImpliedQuantity', 'MissingQuantity' // quantities from Transifex may vary
|
||||
disable 'GradleDependency'
|
||||
disable 'IconColors'
|
||||
disable 'IconLauncherShape'
|
||||
disable 'IconMissingDensityFolder'
|
||||
disable 'ImpliedQuantity', 'MissingQuantity'
|
||||
disable 'MissingTranslation', 'ExtraTranslation' // translations from Transifex are not always up to date
|
||||
disable 'Recycle' // doesn't understand Lombok's @Cleanup
|
||||
disable 'RtlEnabled'
|
||||
disable 'RtlHardcoded'
|
||||
disable 'Typos'
|
||||
@@ -71,54 +62,47 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':cert4android')
|
||||
implementation project(':ical4android')
|
||||
implementation project(':vcard4android')
|
||||
compile project(':cert4android')
|
||||
compile project(':dav4android')
|
||||
compile project(':ical4android')
|
||||
compile project(':vcard4android')
|
||||
|
||||
implementation 'androidx.multidex:multidex:2.0.1'
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${versions.kotlin}"
|
||||
compile "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version"
|
||||
|
||||
implementation 'androidx.appcompat:appcompat:1.1.0'
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.1.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0'
|
||||
implementation 'androidx.paging:paging-runtime-ktx:2.1.0'
|
||||
implementation 'androidx.preference:preference:1.1.0'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-alpha02'
|
||||
implementation 'com.google.android:flexbox:1.1.0'
|
||||
implementation 'com.google.android.material:material:1.1.0-alpha10'
|
||||
//noinspection GradleDynamicVersion
|
||||
compile 'com.android.support:appcompat-v7:26.+'
|
||||
//noinspection GradleDynamicVersion
|
||||
compile 'com.android.support:cardview-v7:26.+'
|
||||
//noinspection GradleDynamicVersion
|
||||
compile 'com.android.support:design:26.+'
|
||||
//noinspection GradleDynamicVersion
|
||||
compile 'com.android.support:preference-v14:26.+'
|
||||
|
||||
def room_version = '2.1.0'
|
||||
implementation "androidx.room:room-runtime:$room_version"
|
||||
implementation "androidx.room:room-ktx:$room_version"
|
||||
kapt "androidx.room:room-compiler:$room_version"
|
||||
compile 'com.github.yukuku:ambilwarna:2.0.1'
|
||||
|
||||
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:okhttp:${versions.okhttp}"
|
||||
implementation "com.squareup.okhttp3:logging-interceptor:${versions.okhttp}"
|
||||
implementation 'commons-io:commons-io:2.6'
|
||||
implementation 'dnsjava:dnsjava:2.1.9'
|
||||
implementation 'org.apache.commons:commons-collections4:4.4'
|
||||
implementation 'org.apache.commons:commons-lang3:3.9'
|
||||
compile 'com.squareup.okhttp3:logging-interceptor:3.8.1'
|
||||
compile 'commons-io:commons-io:2.5'
|
||||
compile 'dnsjava:dnsjava:2.1.8'
|
||||
compile 'org.apache.commons:commons-lang3:3.6'
|
||||
compile 'org.apache.commons:commons-collections4:4.1'
|
||||
|
||||
// for tests
|
||||
androidTestImplementation 'androidx.test:runner:1.2.0'
|
||||
androidTestImplementation 'androidx.test:rules:1.2.0'
|
||||
androidTestImplementation 'junit:junit:4.12'
|
||||
androidTestImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}"
|
||||
//noinspection GradleDynamicVersion
|
||||
androidTestCompile('com.android.support.test:runner:+') {
|
||||
exclude group: 'com.android.support', module: 'support-annotations'
|
||||
}
|
||||
//noinspection GradleDynamicVersion
|
||||
androidTestCompile('com.android.support.test:rules:+') {
|
||||
exclude group: 'com.android.support', module: 'support-annotations'
|
||||
}
|
||||
androidTestCompile 'junit:junit:4.12'
|
||||
androidTestCompile 'com.squareup.okhttp3:mockwebserver:3.8.1'
|
||||
|
||||
testImplementation 'junit:junit:4.12'
|
||||
testImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}"
|
||||
testCompile 'junit:junit:4.12'
|
||||
testCompile 'com.squareup.okhttp3:mockwebserver:3.8.1'
|
||||
}
|
||||
+7
-20
@@ -1,8 +1,8 @@
|
||||
|
||||
# ProGuard usage for DAVx⁵:
|
||||
# ProGuard usage for DAVdroid:
|
||||
# shrinking yes (main reason for using ProGuard)
|
||||
# optimization yes
|
||||
# obfuscation no (DAVx⁵ is open-source)
|
||||
# obfuscation no (DAVdroid is open-source)
|
||||
# preverification no
|
||||
|
||||
-dontobfuscate
|
||||
@@ -12,39 +12,26 @@
|
||||
-allowaccessmodification
|
||||
-dontpreverify
|
||||
|
||||
# Kotlin
|
||||
-dontwarn kotlin.**
|
||||
|
||||
# https://github.com/material-components/material-components-android/issues/387
|
||||
-keep class com.google.android.material.tabs.** {*;}
|
||||
|
||||
# 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
|
||||
-keep class ezvcard.property.** { *; } # keep all vCard properties (created at runtime)
|
||||
-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 javax.cache.** # no JCache support in Android
|
||||
-dontwarn net.fortuna.ical4j.model.**
|
||||
-dontwarn org.codehaus.groovy.**
|
||||
-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 javax.annotation.**
|
||||
-dontwarn okio.**
|
||||
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
|
||||
-dontwarn org.conscrypt.**
|
||||
-dontwarn javax.annotation.Nullable
|
||||
-dontwarn javax.annotation.ParametersAreNonnullByDefault
|
||||
|
||||
# dnsjava
|
||||
-dontwarn sun.net.spi.nameservice.** # not available on Android
|
||||
|
||||
# DAVx⁵ + libs
|
||||
-keep class at.bitfire.** { *; } # all DAVx⁵ code is required
|
||||
# DAVdroid + libs
|
||||
-keep class at.bitfire.** { *; } # all DAVdroid code is required
|
||||
@@ -1 +0,0 @@
|
||||
espressoTest
|
||||
@@ -1,74 +0,0 @@
|
||||
/*
|
||||
* 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"))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.Socket;
|
||||
|
||||
import javax.net.ssl.SSLSocket;
|
||||
|
||||
import at.bitfire.cert4android.CustomCertManager;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
|
||||
import static android.support.test.InstrumentationRegistry.getTargetContext;
|
||||
import static junit.framework.TestCase.assertFalse;
|
||||
import static org.apache.commons.lang3.ArrayUtils.contains;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class SSLSocketFactoryCompatTest {
|
||||
|
||||
SSLSocketFactoryCompat factory;
|
||||
MockWebServer server = new MockWebServer();
|
||||
|
||||
@Before
|
||||
public void startServer() throws Exception {
|
||||
factory = new SSLSocketFactoryCompat(new CustomCertManager(getTargetContext().getApplicationContext(), true));
|
||||
server.start();
|
||||
}
|
||||
|
||||
@After
|
||||
public void stopServer() throws Exception {
|
||||
server.shutdown();
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void testUpgradeTLS() throws IOException {
|
||||
Socket s = factory.createSocket(server.getHostName(), server.getPort());
|
||||
assertTrue(s instanceof SSLSocket);
|
||||
|
||||
SSLSocket ssl = (SSLSocket)s;
|
||||
assertFalse(contains(ssl.getEnabledProtocols(), "SSLv3"));
|
||||
assertTrue(contains(ssl.getEnabledProtocols(), "TLSv1"));
|
||||
assertTrue(contains(ssl.getEnabledProtocols(), "TLSv1.1"));
|
||||
assertTrue(contains(ssl.getEnabledProtocols(), "TLSv1.2"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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 org.junit.Test;
|
||||
|
||||
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;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class CollectionInfoTest {
|
||||
|
||||
MockWebServer server = new MockWebServer();
|
||||
|
||||
@Test
|
||||
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(null), server.url("/"));
|
||||
dav.propfind(0, ResourceType.NAME);
|
||||
CollectionInfo info = new CollectionInfo(dav);
|
||||
assertEquals(CollectionInfo.Type.ADDRESS_BOOK, info.getType());
|
||||
assertFalse(info.getReadOnly());
|
||||
assertEquals("My Contacts", info.getDisplayName());
|
||||
assertEquals("My Contacts Description", info.getDescription());
|
||||
|
||||
// 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(null), server.url("/"));
|
||||
dav.propfind(0, ResourceType.NAME);
|
||||
info = new CollectionInfo(dav);
|
||||
assertEquals(CollectionInfo.Type.CALENDAR, info.getType());
|
||||
assertTrue(info.getReadOnly());
|
||||
assertNull(info.getDisplayName());
|
||||
assertEquals("My Calendar", info.getDescription());
|
||||
assertEquals(0xFFFF0000, (int)info.getColor());
|
||||
assertEquals("tzdata", info.getTimeZone());
|
||||
assertTrue(info.getSupportsVEVENT());
|
||||
assertTrue(info.getSupportsVTODO());
|
||||
}
|
||||
|
||||
@Test
|
||||
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 = new CollectionInfo(values);
|
||||
assertEquals(1, (long)info.getId());
|
||||
assertEquals(1, (long)info.getServiceID());
|
||||
assertEquals("http://example.com", info.getUrl());
|
||||
assertTrue(info.getReadOnly());
|
||||
assertEquals("display name", info.getDisplayName());
|
||||
assertEquals("description", info.getDescription());
|
||||
assertEquals(0xFFFF0000, (int)info.getColor());
|
||||
assertEquals("tzdata", info.getTimeZone());
|
||||
assertTrue(info.getSupportsVEVENT());
|
||||
assertTrue(info.getSupportsVTODO());
|
||||
assertTrue(info.getSelected());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
/*
|
||||
* 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 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 CollectionTest {
|
||||
|
||||
private lateinit var httpClient: HttpClient
|
||||
private val server = MockWebServer()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
httpClient = HttpClient.Builder().build()
|
||||
}
|
||||
|
||||
@After
|
||||
fun shutDown() {
|
||||
httpClient.close()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testFromDavResponseAddressBook() {
|
||||
// r/w address book
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
|
||||
"<response>" +
|
||||
" <href>/</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <resourcetype><collection/><CARD:addressbook/></resourcetype>" +
|
||||
" <displayname>My Contacts</displayname>" +
|
||||
" <CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(Collection.TYPE_ADDRESSBOOK, info.type)
|
||||
assertTrue(info.privWriteContent)
|
||||
assertTrue(info.privUnbind)
|
||||
assertNull(info.supportsVEVENT)
|
||||
assertNull(info.supportsVTODO)
|
||||
assertNull(info.supportsVJOURNAL)
|
||||
assertEquals("My Contacts", info.displayName)
|
||||
assertEquals("My Contacts Description", info.description)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testFromDavResponseCalendar() {
|
||||
// read-only calendar, no display name
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
|
||||
"<response>" +
|
||||
" <href>/</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
|
||||
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
|
||||
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
|
||||
" <CAL:calendar-timezone>tzdata</CAL:calendar-timezone>" +
|
||||
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(Collection.TYPE_CALENDAR, info.type)
|
||||
assertFalse(info.privWriteContent)
|
||||
assertFalse(info.privUnbind)
|
||||
assertNull(info.displayName)
|
||||
assertEquals("My Calendar", info.description)
|
||||
assertEquals(0xFFFF0000.toInt(), info.color)
|
||||
assertEquals("tzdata", info.timezone)
|
||||
assertTrue(info.supportsVEVENT!!)
|
||||
assertTrue(info.supportsVTODO!!)
|
||||
assertTrue(info.supportsVJOURNAL!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testFromDavResponseWebcal() {
|
||||
// Webcal subscription
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CS='http://calendarserver.org/ns/'>" +
|
||||
"<response>" +
|
||||
" <href>/webcal1</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <displayname>Sample Subscription</displayname>" +
|
||||
" <resourcetype><collection/><CS:subscribed/></resourcetype>" +
|
||||
" <CS:source><href>webcals://example.com/1.ics</href></CS:source>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(Collection.TYPE_WEBCAL, info.type)
|
||||
assertEquals("Sample Subscription", info.displayName)
|
||||
assertEquals(HttpUrl.get("https://example.com/1.ics"), info.source)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import okhttp3.HttpUrl
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class DaoToolsTest {
|
||||
|
||||
private lateinit var db: AppDatabase
|
||||
|
||||
@Before
|
||||
fun createDb() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().context
|
||||
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build()
|
||||
}
|
||||
|
||||
@After
|
||||
fun closeDb() {
|
||||
db.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSyncAll() {
|
||||
val serviceDao = db.serviceDao()
|
||||
val service = Service(id=0, accountName="test", type=Service.TYPE_CALDAV, principal = null)
|
||||
service.id = serviceDao.insertOrReplace(service)
|
||||
|
||||
val homeSetDao = db.homeSetDao()
|
||||
val entry1 = HomeSet(id=1, serviceId=service.id, url=HttpUrl.get("https://example.com/1"))
|
||||
val entry3 = HomeSet(id=3, serviceId=service.id, url=HttpUrl.get("https://example.com/3"))
|
||||
val oldItems = listOf(
|
||||
entry1,
|
||||
HomeSet(id=2, serviceId=service.id, url=HttpUrl.get("https://example.com/2")),
|
||||
entry3
|
||||
)
|
||||
homeSetDao.insert(oldItems)
|
||||
|
||||
val newItems = mutableMapOf<HttpUrl, HomeSet>()
|
||||
newItems[entry1.url] = entry1
|
||||
|
||||
// no id, because identity is given by the url
|
||||
val updated = HomeSet(id=0, serviceId=service.id,
|
||||
url=HttpUrl.get("https://example.com/2"), displayName="Updated Entry")
|
||||
newItems[updated.url] = updated
|
||||
|
||||
val created = HomeSet(id=4, serviceId=service.id, url=HttpUrl.get("https://example.com/4"))
|
||||
newItems[created.url] = created
|
||||
|
||||
DaoTools(homeSetDao).syncAll(oldItems, newItems) { it.url }
|
||||
|
||||
val afterSync = homeSetDao.getByService(service.id)
|
||||
assertEquals(afterSync.size, 3)
|
||||
assertFalse(afterSync.contains(entry3))
|
||||
assertTrue(afterSync.contains(entry1))
|
||||
assertTrue(afterSync.contains(updated))
|
||||
assertTrue(afterSync.contains(created))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* 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))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
/*
|
||||
* 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))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* 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 org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
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.HttpClient;
|
||||
import at.bitfire.davdroid.log.Logger;
|
||||
import at.bitfire.davdroid.ui.setup.DavResourceFinder.Configuration.ServiceInfo;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.mockwebserver.Dispatcher;
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import okhttp3.mockwebserver.RecordedRequest;
|
||||
|
||||
import static android.support.test.InstrumentationRegistry.getTargetContext;
|
||||
import static junit.framework.TestCase.assertFalse;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class DavResourceFinderTest {
|
||||
|
||||
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";
|
||||
|
||||
@Before
|
||||
public void initServerAndClient() throws Exception {
|
||||
server.setDispatcher(new TestDispatcher());
|
||||
server.start();
|
||||
|
||||
credentials = new LoginCredentials(URI.create("/"), "mock", "12345");
|
||||
finder = new DavResourceFinder(getTargetContext(), credentials);
|
||||
|
||||
client = HttpClient.create(null);
|
||||
client = HttpClient.addAuthentication(client, credentials.getUserName(), credentials.getPassword());
|
||||
}
|
||||
|
||||
@After
|
||||
public void stopServer() throws Exception {
|
||||
server.shutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
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.getCollections().size());
|
||||
assertEquals(0, info.getHomeSets().size());
|
||||
|
||||
// recognize home set
|
||||
dav.propfind(0, AddressbookHomeSet.NAME);
|
||||
finder.rememberIfAddressBookOrHomeset(dav, info = new ServiceInfo());
|
||||
assertEquals(0, info.getCollections().size());
|
||||
assertEquals(1, info.getHomeSets().size());
|
||||
assertEquals(server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET + "/").uri(), info.getHomeSets().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.getCollections().size());
|
||||
assertEquals(server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK + "/").uri(), info.getCollections().keySet().iterator().next());
|
||||
assertEquals(0, info.getHomeSets().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
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));
|
||||
}
|
||||
|
||||
@Test
|
||||
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)) {
|
||||
MockResponse authenticate = new MockResponse().setResponseCode(401);
|
||||
authenticate.setHeader("WWW-Authenticate", "Basic realm=\"test\"");
|
||||
return authenticate;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Logger.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"));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
/*
|
||||
* 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()
|
||||
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,4 +1,11 @@
|
||||
<?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"
|
||||
@@ -18,6 +25,13 @@
|
||||
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
|
||||
<!--
|
||||
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"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="18"/>
|
||||
|
||||
<!-- other permissions -->
|
||||
<!-- android.permission-group.CONTACTS -->
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||
@@ -26,10 +40,6 @@
|
||||
<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
|
||||
@@ -39,76 +49,27 @@
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppThemeExt"
|
||||
android:theme="@style/AppTheme"
|
||||
tools:ignore="UnusedAttribute">
|
||||
|
||||
<service android:name=".DavService"/>
|
||||
|
||||
<activity
|
||||
android:name=".ui.AccountsActivity"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppTheme.NoActionBar">
|
||||
<receiver
|
||||
android:name=".model.Settings$ReinitSettingsReceiver"
|
||||
android:exported="false"
|
||||
android:process=":sync">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
<action android:name="at.bitfire.davdroid.REINIT_SETTINGS"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.AboutActivity"
|
||||
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"
|
||||
</receiver>
|
||||
<receiver
|
||||
android:name=".AccountSettings$AppUpdatedReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.APPLICATION_PREFERENCES"/>
|
||||
<action android:name="android.intent.action.PACKAGE_REPLACED" />
|
||||
<data android:scheme="package" android:path="at.bitfire.davdroid" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</receiver>
|
||||
|
||||
<activity
|
||||
android:name=".ui.setup.LoginActivity"
|
||||
android:label="@string/login_title"
|
||||
android:parentActivityName=".ui.AccountsActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.account.AccountActivity"
|
||||
android:parentActivityName=".ui.AccountsActivity"
|
||||
android:theme="@style/AppTheme.NoActionBar"/>
|
||||
<activity android:name=".ui.AccountSettingsActivity"/>
|
||||
<activity android:name=".ui.CreateAddressBookActivity"
|
||||
android:label="@string/create_addressbook"/>
|
||||
<activity android:name=".ui.CreateCalendarActivity"
|
||||
android:label="@string/create_calendar"/>
|
||||
|
||||
<activity
|
||||
android:name=".ui.DebugInfoActivity"
|
||||
android:parentActivityName=".ui.AppSettingsActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/debug_info_title">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BUG_REPORT"/>
|
||||
</intent-filter>
|
||||
</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>
|
||||
|
||||
<!-- account type "DAVx⁵" -->
|
||||
<!-- account type "DAVdroid" -->
|
||||
<service
|
||||
android:name=".syncadapter.AccountAuthenticatorService"
|
||||
android:exported="false">
|
||||
@@ -123,6 +84,7 @@
|
||||
<service
|
||||
android:name=".syncadapter.CalendarsSyncAdapterService"
|
||||
android:exported="true"
|
||||
android:process=":sync"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
@@ -132,9 +94,11 @@
|
||||
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"/>
|
||||
@@ -145,7 +109,7 @@
|
||||
android:resource="@xml/sync_tasks"/>
|
||||
</service>
|
||||
|
||||
<!-- account type "DAVx⁵ Address book" -->
|
||||
<!-- account type "DAVdroid Address book" -->
|
||||
<service
|
||||
android:name=".syncadapter.NullAuthenticatorService"
|
||||
android:exported="false">
|
||||
@@ -166,6 +130,7 @@
|
||||
<service
|
||||
android:name=".syncadapter.AddressBooksSyncAdapterService"
|
||||
android:exported="true"
|
||||
android:process=":sync"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
@@ -178,6 +143,7 @@
|
||||
<service
|
||||
android:name=".syncadapter.ContactsSyncAdapterService"
|
||||
android:exported="true"
|
||||
android:process=":sync"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
@@ -191,6 +157,85 @@
|
||||
android:resource="@xml/contacts"/>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".DavService"
|
||||
android:enabled="true">
|
||||
</service>
|
||||
|
||||
<receiver android:name=".AccountsChangedReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.LOGIN_ACCOUNTS_CHANGED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver android:name=".PackageChangedReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.PACKAGE_ADDED"/>
|
||||
<action android:name="android.intent.action.PACKAGE_FULLY_REMOVED"/>
|
||||
<data android:scheme="package"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<activity
|
||||
android:name=".ui.AccountsActivity"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppTheme.NoActionBar">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.AboutActivity"
|
||||
android:label="@string/app_name"
|
||||
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"/>
|
||||
|
||||
<activity
|
||||
android:name=".ui.setup.LoginActivity"
|
||||
android:label="@string/login_title"
|
||||
android:parentActivityName=".ui.AccountsActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.AccountActivity"
|
||||
android:parentActivityName=".ui.AccountsActivity">
|
||||
</activity>
|
||||
<activity android:name=".ui.AccountSettingsActivity"/>
|
||||
<activity android:name=".ui.CreateAddressBookActivity"
|
||||
android:label="@string/create_addressbook"/>
|
||||
<activity android:name=".ui.CreateCalendarActivity"
|
||||
android:label="@string/create_calendar"/>
|
||||
|
||||
<activity
|
||||
android:name=".ui.DebugInfoActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/debug_info_title">
|
||||
</activity>
|
||||
<provider
|
||||
android:name="android.support.v4.content.FileProvider"
|
||||
android:authorities="@string/authority_log_provider"
|
||||
android:grantUriPermissions="true"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/log_paths" />
|
||||
</provider>
|
||||
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,153 @@
|
||||
<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>
|
||||
@@ -0,0 +1,28 @@
|
||||
<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>
|
||||
@@ -0,0 +1,23 @@
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<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>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,479 @@
|
||||
/*
|
||||
* 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.accounts.AccountManager
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.*
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Parcel
|
||||
import android.os.RemoteException
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.ServiceDB.*
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
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.vcard4android.ContactsStorageException
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import okhttp3.HttpUrl
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
class AccountSettings @Throws(InvalidAccountException::class) constructor(
|
||||
val context: Context,
|
||||
val account: Account
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
val CURRENT_VERSION = 7
|
||||
val KEY_SETTINGS_VERSION = "version"
|
||||
|
||||
val KEY_USERNAME = "user_name"
|
||||
|
||||
val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false)
|
||||
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
|
||||
*/
|
||||
val KEY_TIME_RANGE_PAST_DAYS = "time_range_past_days"
|
||||
val 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 */
|
||||
val 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
|
||||
*/
|
||||
val KEY_CONTACT_GROUP_METHOD = "contact_group_method"
|
||||
|
||||
@JvmField
|
||||
val SYNC_INTERVAL_MANUALLY = -1L
|
||||
|
||||
fun initialUserData(userName: String): Bundle {
|
||||
val bundle = Bundle(2)
|
||||
bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
|
||||
bundle.putString(KEY_USERNAME, userName)
|
||||
return bundle
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
val accountManager: AccountManager = AccountManager.get(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 username(): String? = accountManager.getUserData(account, KEY_USERNAME)
|
||||
fun username(userName: String) = accountManager.setUserData(account, KEY_USERNAME, userName)
|
||||
|
||||
fun password(): String? = accountManager.getPassword(account)
|
||||
fun password(password: String) = accountManager.setPassword(account, password)
|
||||
|
||||
|
||||
// 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() = 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>? =
|
||||
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)
|
||||
if (strDays != null) {
|
||||
val days = Integer.valueOf(strDays)
|
||||
return if (days < 0) null else days
|
||||
} else
|
||||
return DEFAULT_TIME_RANGE_PAST_DAYS
|
||||
}
|
||||
|
||||
fun setTimeRangePastDays(days: Int?) =
|
||||
accountManager.setUserData(account, KEY_TIME_RANGE_PAST_DAYS, (days ?: -1).toString())
|
||||
|
||||
fun getManageCalendarColors() = accountManager.getUserData(account, KEY_MANAGE_CALENDAR_COLORS) == null
|
||||
fun setManageCalendarColors(manage: Boolean) =
|
||||
accountManager.setUserData(account, KEY_MANAGE_CALENDAR_COLORS, if (manage) null else "0")
|
||||
|
||||
|
||||
// CardDAV settings
|
||||
|
||||
fun getGroupMethod(): GroupMethod {
|
||||
val name = accountManager.getUserData(account, KEY_CONTACT_GROUP_METHOD)
|
||||
return if (name != null)
|
||||
GroupMethod.valueOf(name)
|
||||
else
|
||||
GroupMethod.GROUP_VCARDS
|
||||
}
|
||||
|
||||
fun setGroupMethod(method: GroupMethod) {
|
||||
val name = if (method == GroupMethod.GROUP_VCARDS) null else method.name
|
||||
accountManager.setUserData(account, KEY_CONTACT_GROUP_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)
|
||||
accountManager.setUserData(account, KEY_SETTINGS_VERSION, toVersion.toString())
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't update account settings", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun update_6_7() {
|
||||
// add calendar colors
|
||||
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider ->
|
||||
try {
|
||||
AndroidCalendar.insertColors(provider, account)
|
||||
} finally {
|
||||
if (Build.VERSION.SDK_INT >= 24)
|
||||
provider.close()
|
||||
else
|
||||
provider.release()
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
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, App.addressBooksAuthority, 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")
|
||||
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), App.addressBookAccountType)
|
||||
if (!accountManager.addAccountExplicitly(addressBookAccount, null, LocalAddressBook.initialUserData(account, info.url)))
|
||||
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()
|
||||
if (Build.VERSION.SDK_INT >= 24)
|
||||
provider.close()
|
||||
else
|
||||
provider.release()
|
||||
}
|
||||
}
|
||||
|
||||
// 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, App.addressBooksAuthority, 1)
|
||||
setSyncInterval(App.addressBooksAuthority, 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")
|
||||
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.getURL()
|
||||
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 {
|
||||
if (Build.VERSION.SDK_INT >= 24)
|
||||
client.close()
|
||||
else
|
||||
@Suppress("deprecation")
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if (Build.VERSION.SDK_INT >= 24)
|
||||
client.close()
|
||||
else
|
||||
@Suppress("deprecation")
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
AndroidTaskList.acquireTaskProvider(context.contentResolver)?.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")
|
||||
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 info 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.updateSettings(values)
|
||||
|
||||
val url = accountManager.getUserData(account, "addressbook_url")
|
||||
if (!url.isNullOrEmpty())
|
||||
addr.setURL(url)
|
||||
accountManager.setUserData(account, "addressbook_url", null)
|
||||
|
||||
val cTag = accountManager.getUserData (account, "addressbook_ctag")
|
||||
if (!cTag.isNullOrEmpty())
|
||||
addr.setCTag(cTag)
|
||||
accountManager.setUserData(account, "addressbook_ctag", null)
|
||||
} finally {
|
||||
if (Build.VERSION.SDK_INT >= 24)
|
||||
provider.close()
|
||||
else
|
||||
@Suppress("deprecation")
|
||||
provider.release()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AppUpdatedReceiver: BroadcastReceiver() {
|
||||
|
||||
@SuppressLint("UnsafeProtectedBroadcastReceiver,MissingPermission")
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
Logger.log.info("DAVdroid was updated, checking for AccountSettings version")
|
||||
|
||||
// peek into AccountSettings to initiate a possible migration
|
||||
val accountManager = AccountManager.get(context)
|
||||
for (account in accountManager.getAccountsByType(context.getString(R.string.account_type)))
|
||||
try {
|
||||
Logger.log.info("Checking account ${account.name}")
|
||||
AccountSettings(context, account)
|
||||
} catch(e: InvalidAccountException) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't check for updated account settings", e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.accounts.OnAccountsUpdateListener
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import java.util.*
|
||||
|
||||
class AccountsChangedReceiver: BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
|
||||
private val listeners = LinkedList<OnAccountsUpdateListener>()
|
||||
|
||||
@JvmStatic
|
||||
fun registerListener(listener: OnAccountsUpdateListener, callImmediately: Boolean) {
|
||||
listeners += listener
|
||||
if (callImmediately)
|
||||
listener.onAccountsUpdated(null)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun unregisterListener(listener: OnAccountsUpdateListener) {
|
||||
listeners -= listener
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == AccountManager.LOGIN_ACCOUNTS_CHANGED_ACTION) {
|
||||
val serviceIntent = Intent(context, DavService::class.java)
|
||||
serviceIntent.action = DavService.ACTION_ACCOUNTS_UPDATED
|
||||
context.startService(serviceIntent)
|
||||
|
||||
for (listener in listeners)
|
||||
listener.onAccountsUpdated(null)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,92 +8,52 @@
|
||||
|
||||
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.DebugInfoActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import java.util.logging.Level
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
@Suppress("unused")
|
||||
class App: MultiDexApplication(), Thread.UncaughtExceptionHandler {
|
||||
class App: Application() {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField val DISTRUST_SYSTEM_CERTIFICATES = "distrustSystemCerts"
|
||||
@JvmField val LOG_TO_EXTERNAL_STORAGE = "logToExternalStorage"
|
||||
@JvmField val OVERRIDE_PROXY = "overrideProxy"
|
||||
@JvmField val OVERRIDE_PROXY_HOST = "overrideProxyHost"
|
||||
@JvmField val OVERRIDE_PROXY_PORT = "overrideProxyPort"
|
||||
|
||||
@JvmField val OVERRIDE_PROXY_HOST_DEFAULT = "localhost"
|
||||
@JvmField val OVERRIDE_PROXY_PORT_DEFAULT = 8118
|
||||
|
||||
lateinit var addressBookAccountType: String
|
||||
lateinit var addressBooksAuthority: String
|
||||
|
||||
@JvmStatic
|
||||
fun getLauncherBitmap(context: Context): Bitmap? {
|
||||
val drawableLogo = AppCompatResources.getDrawable(context, R.mipmap.ic_launcher)
|
||||
val drawableLogo = if (android.os.Build.VERSION.SDK_INT >= 21)
|
||||
context.getDrawable(R.mipmap.ic_launcher)
|
||||
else
|
||||
@Suppress("deprecation")
|
||||
context.resources.getDrawable(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.FLAVOR == FLAVOR_STANDARD)
|
||||
Thread.setDefaultUncaughtExceptionHandler(this)
|
||||
CustomCertificates.reinitCertManager(this)
|
||||
Logger.reinitLogger(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)
|
||||
}
|
||||
}
|
||||
|
||||
override fun uncaughtException(t: Thread, e: Throwable) {
|
||||
Logger.log.log(Level.SEVERE, "Unhandled exception!", e)
|
||||
|
||||
val intent = Intent(this, DebugInfoActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
intent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
|
||||
startActivity(intent)
|
||||
|
||||
exitProcess(1)
|
||||
addressBookAccountType = getString(R.string.account_type_address_book)
|
||||
addressBooksAuthority = getString(R.string.address_books_authority)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.content.ContentProviderClient
|
||||
import android.os.Build
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun ContentProviderClient.closeCompat() {
|
||||
if (Build.VERSION.SDK_INT >= 24)
|
||||
close()
|
||||
else
|
||||
release()
|
||||
}
|
||||
@@ -5,26 +5,19 @@
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
object Constants {
|
||||
|
||||
const val DAVDROID_GREEN_RGBA = 0xFF8bc34a.toInt()
|
||||
// notification IDs
|
||||
@JvmField val NOTIFICATION_EXTERNAL_FILE_LOGGING = 1
|
||||
@JvmField val NOTIFICATION_REFRESH_COLLECTIONS = 2
|
||||
@JvmField val NOTIFICATION_CONTACTS_SYNC = 10
|
||||
@JvmField val NOTIFICATION_CALENDAR_SYNC = 11
|
||||
@JvmField val NOTIFICATION_TASK_SYNC = 12
|
||||
@JvmField val NOTIFICATION_PERMISSIONS = 20
|
||||
|
||||
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"
|
||||
@JvmField
|
||||
val DEFAULT_SYNC_INTERVAL = 4 * 3600L // 4 hours
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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 at.bitfire.cert4android.CustomCertManager
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.Settings
|
||||
import okhttp3.internal.tls.OkHostnameVerifier
|
||||
import javax.net.ssl.HostnameVerifier
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
|
||||
object CustomCertificates {
|
||||
|
||||
@JvmField var certManager: CustomCertManager? = null
|
||||
var sslSocketFactoryCompat: SSLSocketFactory? = null
|
||||
var hostnameVerifier: HostnameVerifier? = null
|
||||
|
||||
fun reinitCertManager(context: Context) {
|
||||
if (BuildConfig.customCerts) {
|
||||
certManager?.close()
|
||||
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val settings = Settings(dbHelper.readableDatabase)
|
||||
|
||||
val mgr = CustomCertManager(context.applicationContext, !settings.getBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, false))
|
||||
certManager = mgr
|
||||
hostnameVerifier = mgr.hostnameVerifier(OkHostnameVerifier.INSTANCE)
|
||||
sslSocketFactoryCompat = SSLSocketFactoryCompat(mgr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,53 +9,44 @@
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.content.ContentResolver
|
||||
import android.app.Service
|
||||
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 androidx.room.Transaction
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.*
|
||||
import android.support.v4.app.NotificationManagerCompat
|
||||
import android.support.v7.app.NotificationCompat
|
||||
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.*
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.*
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
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.ui.DebugInfoActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
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.*
|
||||
import java.util.logging.Level
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class DavService: android.app.Service() {
|
||||
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"
|
||||
|
||||
val DAV_COLLECTION_PROPERTIES = arrayOf(
|
||||
ResourceType.NAME,
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
DisplayName.NAME,
|
||||
AddressbookDescription.NAME, SupportedAddressData.NAME,
|
||||
CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME,
|
||||
Source.NAME
|
||||
)
|
||||
|
||||
@JvmField val ACTION_ACCOUNTS_UPDATED = "accountsUpdated"
|
||||
@JvmField val ACTION_REFRESH_COLLECTIONS = "refreshCollections"
|
||||
@JvmField val EXTRA_DAV_SERVICE_ID = "davServiceID"
|
||||
}
|
||||
|
||||
private val runningRefresh = HashSet<Long>()
|
||||
@@ -67,23 +58,13 @@ class DavService: android.app.Service() {
|
||||
val id = intent.getLongExtra(EXTRA_DAV_SERVICE_ID, -1)
|
||||
|
||||
when (intent.action) {
|
||||
ACTION_ACCOUNTS_UPDATED ->
|
||||
cleanupAccounts()
|
||||
ACTION_REFRESH_COLLECTIONS ->
|
||||
if (runningRefresh.add(id)) {
|
||||
refreshingStatusListeners.forEach { listener ->
|
||||
listener.get()?.onDavRefreshStatusChanged(id, true)
|
||||
}
|
||||
thread { refreshCollections(id) }
|
||||
refreshingStatusListeners.forEach { it.get()?.onDavRefreshStatusChanged(id, true) }
|
||||
}
|
||||
|
||||
ACTION_FORCE_SYNC -> {
|
||||
val uri = intent.data!!
|
||||
val authority = uri.authority!!
|
||||
val account = Account(
|
||||
uri.pathSegments[1],
|
||||
uri.pathSegments[0]
|
||||
)
|
||||
forceSync(authority, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,9 +85,9 @@ class DavService: android.app.Service() {
|
||||
inner class InfoBinder: Binder() {
|
||||
fun isRefreshing(id: Long) = runningRefresh.contains(id)
|
||||
|
||||
fun addRefreshingStatusListener(listener: RefreshingStatusListener, callImmediateIfRunning: Boolean) {
|
||||
fun addRefreshingStatusListener(listener: RefreshingStatusListener, callImmediate: Boolean) {
|
||||
refreshingStatusListeners += WeakReference<RefreshingStatusListener>(listener)
|
||||
if (callImmediateIfRunning)
|
||||
if (callImmediate)
|
||||
runningRefresh.forEach { id -> listener.onDavRefreshStatusChanged(id, true) }
|
||||
}
|
||||
|
||||
@@ -128,190 +109,203 @@ class DavService: android.app.Service() {
|
||||
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)
|
||||
@SuppressLint("MissingPermission")
|
||||
fun cleanupAccounts() {
|
||||
Logger.log.info("Cleaning up orphaned accounts")
|
||||
|
||||
OpenHelper(this).use { dbHelper ->
|
||||
val db = dbHelper.writableDatabase
|
||||
|
||||
val sqlAccountNames = LinkedList<String>()
|
||||
val accountNames = HashSet<String>()
|
||||
val accountManager = AccountManager.get(this)
|
||||
for (account in accountManager.getAccountsByType(getString(R.string.account_type))) {
|
||||
sqlAccountNames.add(DatabaseUtils.sqlEscapeString(account.name))
|
||||
accountNames += account.name
|
||||
}
|
||||
|
||||
// delete orphaned address book accounts
|
||||
accountManager.getAccountsByType(App.addressBookAccountType)
|
||||
.map { LocalAddressBook(this, it, null) }
|
||||
.forEach {
|
||||
try {
|
||||
if (!accountNames.contains(it.getMainAccount().name))
|
||||
it.delete()
|
||||
} catch(e: ContactsStorageException) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't get address book main account", e)
|
||||
}
|
||||
}
|
||||
|
||||
// delete orphaned services in DB
|
||||
if (sqlAccountNames.isEmpty())
|
||||
db.delete(Services._TABLE, null, null)
|
||||
else
|
||||
db.delete(Services._TABLE, "${Services.ACCOUNT_NAME} NOT IN (${sqlAccountNames.joinToString(",")})", null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshCollections(serviceId: Long) {
|
||||
val db = AppDatabase.getInstance(this)
|
||||
val homeSetDao = db.homeSetDao()
|
||||
val collectionDao = db.collectionDao()
|
||||
fun refreshCollections(service: Long) {
|
||||
OpenHelper(this@DavService).use { dbHelper ->
|
||||
val db = dbHelper.writableDatabase
|
||||
|
||||
val service = db.serviceDao().get(serviceId) ?: throw IllegalArgumentException("Service not found")
|
||||
val account = Account(service.accountName, getString(R.string.account_type))
|
||||
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 homeSets = homeSetDao.getByService(serviceId).associateBy { it.url }.toMutableMap()
|
||||
val collections = collectionDao.getByService(serviceId).associateBy { it.url }.toMutableMap()
|
||||
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")
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given URL defines home sets and adds them to the home set list.
|
||||
*
|
||||
* @throws java.io.IOException
|
||||
* @throws HttpException
|
||||
* @throws at.bitfire.dav4jvm.exception.DavException
|
||||
*/
|
||||
fun queryHomeSets(client: OkHttpClient, url: HttpUrl, recurse: Boolean = true) {
|
||||
val related = mutableSetOf<HttpUrl>()
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
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()
|
||||
DatabaseUtils.cursorRowToContentValues(cursor, values)
|
||||
values.getAsString(Collections.URL)?.let { url ->
|
||||
HttpUrl.parse(url)?.let { collections.put(it, CollectionInfo(values)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
collections
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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.
|
||||
* @param dav DavResource to check
|
||||
*/
|
||||
@Throws(IOException::class, HttpException::class, DavException::class)
|
||||
fun queryHomeSets(dav: DavResource) {
|
||||
when (serviceType) {
|
||||
Services.SERVICE_CARDDAV -> {
|
||||
dav.propfind(0, AddressbookHomeSet.NAME, GroupMembership.NAME)
|
||||
for ((resource, addressbookHomeSet) in dav.findProperties(AddressbookHomeSet.NAME) as List<Pair<DavResource, AddressbookHomeSet>>)
|
||||
for (href in addressbookHomeSet.hrefs)
|
||||
resource.location.resolve(href)?.let { homeSets += UrlUtils.withTrailingSlash(it) }
|
||||
}
|
||||
Services.SERVICE_CALDAV -> {
|
||||
dav.propfind(0, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME)
|
||||
for ((resource, calendarHomeSet) in dav.findProperties(CalendarHomeSet.NAME) as List<Pair<DavResource, CalendarHomeSet>>)
|
||||
for (href in calendarHomeSet.hrefs)
|
||||
resource.location.resolve(href)?.let { homeSets.add(UrlUtils.withTrailingSlash(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val dav = DavResource(client, url)
|
||||
when (service.type) {
|
||||
Service.TYPE_CARDDAV ->
|
||||
try {
|
||||
dav.propfind(0, DisplayName.NAME, AddressbookHomeSet.NAME, GroupMembership.NAME) { response, _ ->
|
||||
response[AddressbookHomeSet::class.java]?.let { homeSet ->
|
||||
for (href in homeSet.hrefs)
|
||||
dav.location.resolve(href)?.let {
|
||||
val foundUrl = UrlUtils.withTrailingSlash(it)
|
||||
homeSets[foundUrl] = HomeSet(0, service.id, foundUrl)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Service.TYPE_CALDAV -> {
|
||||
try {
|
||||
dav.propfind(0, DisplayName.NAME, 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 {
|
||||
val foundUrl = UrlUtils.withTrailingSlash(it)
|
||||
homeSets[foundUrl] = HomeSet(0, service.id, foundUrl)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
for (resource in related)
|
||||
queryHomeSets(client, resource, false)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@Transaction
|
||||
fun saveHomesets() {
|
||||
DaoTools(homeSetDao).syncAll(
|
||||
homeSetDao.getByService(serviceId),
|
||||
homeSets
|
||||
) { it.url }
|
||||
}
|
||||
|
||||
@Transaction
|
||||
fun saveCollections() {
|
||||
DaoTools(collectionDao).syncAll(
|
||||
collectionDao.getByService(serviceId),
|
||||
collections
|
||||
) { it.url }
|
||||
}
|
||||
try {
|
||||
Logger.log.info("Refreshing $serviceType collections of service #$service")
|
||||
|
||||
fun saveResults() {
|
||||
saveHomesets()
|
||||
saveCollections()
|
||||
}
|
||||
|
||||
try {
|
||||
Logger.log.info("Refreshing ${service.type} 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
|
||||
// create authenticating OkHttpClient (credentials taken from account settings)
|
||||
val httpClient = HttpClient.create(this@DavService, account)
|
||||
|
||||
// refresh home set list (from principal)
|
||||
service.principal?.let { principalUrl ->
|
||||
readPrincipal()?.let { principalUrl ->
|
||||
Logger.log.fine("Querying principal $principalUrl for home sets")
|
||||
queryHomeSets(httpClient, principalUrl)
|
||||
val principal = DavResource(httpClient, principalUrl)
|
||||
queryHomeSets(principal)
|
||||
|
||||
// refresh home sets: calendar-proxy-read/write-for
|
||||
for ((resource, proxyRead) in principal.findProperties(CalendarProxyReadFor.NAME) as List<Pair<DavResource, CalendarProxyReadFor>>)
|
||||
for (href in proxyRead.hrefs) {
|
||||
Logger.log.fine("Principal is a read-only proxy for $href, checking for home sets")
|
||||
resource.location.resolve(href)?.let { queryHomeSets(DavResource(httpClient, it)) }
|
||||
}
|
||||
for ((resource, proxyWrite) in principal.findProperties(CalendarProxyWriteFor.NAME) as List<Pair<DavResource, CalendarProxyWriteFor>>)
|
||||
for (href in proxyWrite.hrefs) {
|
||||
Logger.log.fine("Principal is a read/write proxy for $href, checking for home sets")
|
||||
resource.location.resolve(href)?.let { queryHomeSets(DavResource(httpClient, it)) }
|
||||
}
|
||||
|
||||
// refresh home sets: direct group memberships
|
||||
(principal.properties[GroupMembership.NAME] as GroupMembership?)?.let { groupMembership ->
|
||||
for (href in groupMembership.hrefs) {
|
||||
Logger.log.fine("Principal is member of group $href, checking for home sets")
|
||||
principal.location.resolve(href)?.let { url ->
|
||||
try {
|
||||
queryHomeSets(DavResource(httpClient, url))
|
||||
} catch(e: HttpException) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't query member group", e)
|
||||
} catch(e: DavException) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't query member group", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// remember selected collections
|
||||
val selectedCollections = HashSet<HttpUrl>()
|
||||
collections.forEach { (url, collection) ->
|
||||
if (collection.sync)
|
||||
selectedCollections += url
|
||||
}
|
||||
collections.values
|
||||
.filter { it.selected }
|
||||
.forEach { (url,_) -> HttpUrl.parse(url)?.let { selectedCollections.add(it) } }
|
||||
|
||||
// now refresh homesets and their member collections
|
||||
// now refresh collections (taken from home sets)
|
||||
val itHomeSets = homeSets.iterator()
|
||||
while (itHomeSets.hasNext()) {
|
||||
val homeSet = itHomeSets.next()
|
||||
Logger.log.fine("Listing home set ${homeSet.key}")
|
||||
val homeSetUrl = itHomeSets.next()
|
||||
Logger.log.fine("Listing home set $homeSetUrl")
|
||||
|
||||
val homeSet = DavResource(httpClient, homeSetUrl)
|
||||
try {
|
||||
DavResource(httpClient, homeSet.key).propfind(1, *DAV_COLLECTION_PROPERTIES) { response, relation ->
|
||||
if (!response.isSuccess())
|
||||
return@propfind
|
||||
homeSet.propfind(1, *CollectionInfo.DAV_PROPERTIES)
|
||||
val itCollections = IteratorChain<DavResource>(homeSet.members.iterator(), SingletonIterator(homeSet))
|
||||
while (itCollections.hasNext()) {
|
||||
val member = itCollections.next()
|
||||
val info = CollectionInfo(member)
|
||||
info.confirmed = true
|
||||
Logger.log.log(Level.FINE, "Found collection", info)
|
||||
|
||||
if (relation == Response.HrefRelation.SELF) {
|
||||
// this response is about the homeset itself
|
||||
homeSet.value.displayName = response[DisplayName::class.java]?.displayName
|
||||
homeSet.value.privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind ?: true
|
||||
|
||||
} else {
|
||||
val info = Collection.fromDavResponse(response) ?: return@propfind
|
||||
info.serviceId = serviceId
|
||||
info.confirmed = true
|
||||
Logger.log.log(Level.FINE, "Found collection", info)
|
||||
|
||||
if ((service.type == Service.TYPE_CARDDAV && info.type == Collection.TYPE_ADDRESSBOOK) ||
|
||||
(service.type == Service.TYPE_CALDAV && arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(info.type)))
|
||||
collections[response.href] = info
|
||||
}
|
||||
if ((serviceType == Services.SERVICE_CARDDAV && info.type == CollectionInfo.Type.ADDRESS_BOOK) ||
|
||||
(serviceType == Services.SERVICE_CALDAV && info.type == CollectionInfo.Type.CALENDAR))
|
||||
collections[member.location] = info
|
||||
}
|
||||
} catch(e: HttpException) {
|
||||
if (e.code in arrayOf(403, 404, 410))
|
||||
if (e.status in arrayOf(403, 404, 410))
|
||||
// delete home set only if it was not accessible (40x)
|
||||
itHomeSets.remove()
|
||||
}
|
||||
@@ -323,22 +317,18 @@ class DavService: android.app.Service() {
|
||||
val (url, info) = itCollections.next()
|
||||
if (!info.confirmed)
|
||||
try {
|
||||
DavResource(httpClient, url).propfind(0, *DAV_COLLECTION_PROPERTIES) { response, _ ->
|
||||
if (!response.isSuccess())
|
||||
return@propfind
|
||||
val collection = DavResource(httpClient, url)
|
||||
collection.propfind(0, *CollectionInfo.DAV_PROPERTIES)
|
||||
val info = CollectionInfo(collection)
|
||||
info.confirmed = true
|
||||
|
||||
val collection = Collection.fromDavResponse(response) ?: return@propfind
|
||||
collection.confirmed = true
|
||||
|
||||
// remove unusable collections
|
||||
if ((service.type == Service.TYPE_CARDDAV && collection.type != Collection.TYPE_ADDRESSBOOK) ||
|
||||
(service.type == Service.TYPE_CALDAV && !arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(collection.type)) ||
|
||||
(collection.type == Collection.TYPE_WEBCAL && collection.source == null))
|
||||
itCollections.remove()
|
||||
}
|
||||
// remove unusable collections
|
||||
if ((serviceType == Services.SERVICE_CARDDAV && info.type != CollectionInfo.Type.ADDRESS_BOOK) ||
|
||||
(serviceType == Services.SERVICE_CALDAV && info.type != CollectionInfo.Type.CALENDAR))
|
||||
itCollections.remove()
|
||||
} catch(e: HttpException) {
|
||||
if (e.code in arrayOf(403, 404, 410))
|
||||
// delete collection only if it was not accessible (40x)
|
||||
if (e.status in arrayOf(403, 404, 410))
|
||||
// delete collection only if it was not accessible (40x)
|
||||
itCollections.remove()
|
||||
else
|
||||
throw e
|
||||
@@ -347,37 +337,41 @@ class DavService: android.app.Service() {
|
||||
|
||||
// restore selections
|
||||
for (url in selectedCollections)
|
||||
collections[url]?.let { it.sync = true }
|
||||
}
|
||||
collections[url]?.let { it.selected = true }
|
||||
|
||||
saveResults()
|
||||
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)
|
||||
} 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 debugIntent = Intent(this@DavService, 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_problem_notify)
|
||||
.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(serviceId.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS, notify)
|
||||
} finally {
|
||||
runningRefresh.remove(serviceId)
|
||||
refreshingStatusListeners.mapNotNull { it.get() }.forEach {
|
||||
it.onDavRefreshStatusChanged(serviceId, false)
|
||||
val nm = NotificationManagerCompat.from(this@DavService)
|
||||
val notify = NotificationCompat.Builder(this@DavService)
|
||||
.setSmallIcon(R.drawable.ic_error_light)
|
||||
.setLargeIcon(App.getLauncherBitmap(this@DavService))
|
||||
.setContentTitle(getString(R.string.dav_service_refresh_failed))
|
||||
.setContentText(getString(R.string.dav_service_refresh_couldnt_refresh))
|
||||
.setContentIntent(PendingIntent.getActivity(this@DavService, 0, debugIntent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.build()
|
||||
nm.notify(Constants.NOTIFICATION_REFRESH_COLLECTIONS, notify)
|
||||
} finally {
|
||||
runningRefresh.remove(service)
|
||||
refreshingStatusListeners.forEach { it.get()?.onDavRefreshStatusChanged(service, false) }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -6,102 +6,33 @@
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import android.accounts.Account
|
||||
import android.annotation.TargetApi
|
||||
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 {
|
||||
|
||||
@JvmStatic
|
||||
fun ARGBtoCalDAVColor(colorWithAlpha: Int): String {
|
||||
val alpha = (colorWithAlpha shr 24) and 0xFF
|
||||
val color = colorWithAlpha and 0xFFFFFF
|
||||
return String.format("#%06X%02X", color, alpha)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun lastSegmentOfUrl(url: String): String {
|
||||
val httpUrl = HttpUrl.parse(url) ?: throw IllegalArgumentException("url not parsable")
|
||||
|
||||
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()
|
||||
val segments = LinkedList<String>(httpUrl.pathSegments())
|
||||
Collections.reverse(segments)
|
||||
|
||||
return segments.firstOrNull { it.isNotEmpty() } ?: "/"
|
||||
for (segment in segments)
|
||||
if (segment.isNotEmpty())
|
||||
return segment
|
||||
|
||||
return "/"
|
||||
}
|
||||
|
||||
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)
|
||||
if (activeLink != null) {
|
||||
// get DNS servers of active network link and set them for dnsjava so that it can send SRV queries
|
||||
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)
|
||||
} else
|
||||
Logger.log.severe("Couldn't determine DNS servers, dnsjava queries (SRV/TXT records) won't work")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,79 +6,102 @@
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.security.KeyChain
|
||||
import at.bitfire.cert4android.CustomCertManager
|
||||
import at.bitfire.dav4jvm.BasicDigestAuthHandler
|
||||
import at.bitfire.dav4jvm.Constants
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.dav4android.BasicDigestAuthHandler
|
||||
import at.bitfire.dav4android.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.*
|
||||
import okhttp3.internal.tls.OkHostnameVerifier
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.Settings
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
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.*
|
||||
|
||||
class HttpClient private constructor(
|
||||
val okHttpClient: OkHttpClient,
|
||||
private val certManager: CustomCertManager?
|
||||
): AutoCloseable {
|
||||
class HttpClient private constructor() {
|
||||
|
||||
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)
|
||||
private val client = OkHttpClient()
|
||||
private val userAgentInterceptor = UserAgentInterceptor()
|
||||
|
||||
// don't allow redirects by default, because it would break PROPFIND handling
|
||||
.followRedirects(false)
|
||||
|
||||
// add User-Agent to every request
|
||||
.addNetworkInterceptor(UserAgentInterceptor)
|
||||
|
||||
.build()
|
||||
}
|
||||
private val userAgentDate = SimpleDateFormat("yyyy/MM/dd", Locale.US).format(Date(BuildConfig.buildTime))
|
||||
private val userAgent = "DAVdroid/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4android; okhttp3) Android/${Build.VERSION.RELEASE}"
|
||||
|
||||
|
||||
override fun close() {
|
||||
okHttpClient.cache()?.close()
|
||||
certManager?.close()
|
||||
}
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun create(context: Context?, settings: AccountSettings? = null, logger: java.util.logging.Logger = Logger.log): OkHttpClient {
|
||||
var builder = defaultBuilder(context, logger)
|
||||
|
||||
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
|
||||
// use account settings for authentication
|
||||
settings?.let {
|
||||
val userName = it.username()
|
||||
val password = it.password()
|
||||
if (userName != null && password != null)
|
||||
builder = addAuthentication(builder, null, userName, password)
|
||||
}
|
||||
|
||||
private val orig = sharedClient.newBuilder()
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Throws(InvalidAccountException::class)
|
||||
fun create(context: Context, account: Account) =
|
||||
create(context, AccountSettings(context, account))
|
||||
|
||||
|
||||
private fun defaultBuilder(context: Context?, logger: java.util.logging.Logger): OkHttpClient.Builder {
|
||||
val builder = client.newBuilder()
|
||||
|
||||
// use MemorizingTrustManager to manage self-signed certificates
|
||||
if (CustomCertificates.sslSocketFactoryCompat != null && CustomCertificates.certManager != null)
|
||||
builder.sslSocketFactory(CustomCertificates.sslSocketFactoryCompat, CustomCertificates.certManager)
|
||||
CustomCertificates.hostnameVerifier?.let { builder.hostnameVerifier(it) }
|
||||
|
||||
// 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)
|
||||
|
||||
// custom proxy support
|
||||
context?.let {
|
||||
ServiceDB.OpenHelper(it).use { dbHelper ->
|
||||
try {
|
||||
val settings = Settings(dbHelper.readableDatabase)
|
||||
if (settings.getBoolean(App.OVERRIDE_PROXY, false)) {
|
||||
val address = InetSocketAddress(
|
||||
settings.getString(App.OVERRIDE_PROXY_HOST, App.OVERRIDE_PROXY_HOST_DEFAULT),
|
||||
settings.getInt(App.OVERRIDE_PROXY_PORT, App.OVERRIDE_PROXY_PORT_DEFAULT)
|
||||
)
|
||||
|
||||
val proxy = Proxy(Proxy.Type.HTTP, address)
|
||||
builder.proxy(proxy)
|
||||
Logger.log.log(Level.INFO, "Using proxy", proxy)
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Can't set proxy, ignoring", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add User-Agent to every request
|
||||
builder.addNetworkInterceptor(userAgentInterceptor)
|
||||
|
||||
init {
|
||||
// add cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
|
||||
orig.cookieJar(MemoryCookieStore())
|
||||
builder.cookieJar(MemoryCookieStore())
|
||||
|
||||
// add network logging, if requested
|
||||
if (logger.isLoggable(Level.FINEST)) {
|
||||
@@ -86,157 +109,31 @@ class HttpClient private constructor(
|
||||
message -> logger.finest(message)
|
||||
})
|
||||
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
|
||||
orig.addInterceptor(loggingInterceptor)
|
||||
builder.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())
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
constructor(context: Context, host: String?, credentials: Credentials): this(context) {
|
||||
addAuthentication(host, credentials)
|
||||
fun addAuthentication(builder: OkHttpClient.Builder, host: String?, username: String, password: String): OkHttpClient.Builder {
|
||||
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), username, password);
|
||||
return builder
|
||||
.addNetworkInterceptor(authHandler)
|
||||
.authenticator(authHandler)
|
||||
}
|
||||
|
||||
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
|
||||
certificateAlias?.let { alias ->
|
||||
try {
|
||||
val context = requireNotNull(context)
|
||||
|
||||
// get provider certificate and private key
|
||||
val certs = KeyChain.getCertificateChain(context, alias) ?: return@let
|
||||
val key = KeyChain.getPrivateKey(context, alias) ?: return@let
|
||||
logger.fine("Using provider certificate $alias for authentication (chain length: ${certs.size})")
|
||||
|
||||
// create 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 }
|
||||
}
|
||||
|
||||
// HTTP/2 doesn't support client certificates (yet)
|
||||
// see https://tools.ietf.org/html/draft-ietf-httpbis-http2-secondary-certs-04
|
||||
orig.protocols(listOf(Protocol.HTTP_1_1))
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.SEVERE, "Couldn't set up provider certificate authentication", e)
|
||||
}
|
||||
}
|
||||
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(
|
||||
if (keyManager != null) arrayOf(keyManager) else null,
|
||||
arrayOf(trustManager),
|
||||
null)
|
||||
orig.sslSocketFactory(sslContext.socketFactory, trustManager)
|
||||
orig.hostnameVerifier(hostnameVerifier)
|
||||
|
||||
return HttpClient(orig.build(), certManager)
|
||||
@JvmOverloads
|
||||
@JvmStatic
|
||||
fun addAuthentication(client: OkHttpClient, host: String? = null, username: String, password: String): OkHttpClient {
|
||||
val builder = client.newBuilder()
|
||||
addAuthentication(builder, host, username, password)
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
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}"
|
||||
private class UserAgentInterceptor: Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val locale = Locale.getDefault()
|
||||
|
||||
@@ -27,7 +27,7 @@ class MemoryCookieStore: CookieJar {
|
||||
* 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>())!!
|
||||
val storage = MultiKeyMap.multiKeyMap(HashedMap<MultiKey<out String>, Cookie>())!!
|
||||
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
synchronized(storage) {
|
||||
|
||||
@@ -9,50 +9,57 @@
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.accounts.Account
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.WorkerThread
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
import at.bitfire.davdroid.model.Service
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.ServiceDB.Services
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.ical4android.TaskProvider.ProviderName.OpenTasks
|
||||
import kotlin.concurrent.thread
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
|
||||
class PackageChangedReceiver: BroadcastReceiver() {
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_PACKAGE_ADDED || intent.action == Intent.ACTION_PACKAGE_FULLY_REMOVED)
|
||||
updateTaskSync(context)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
@WorkerThread
|
||||
@JvmStatic
|
||||
fun updateTaskSync(context: Context) {
|
||||
val tasksInstalled = LocalTaskList.tasksProviderAvailable(context)
|
||||
Logger.log.info("Tasks provider available = $tasksInstalled")
|
||||
Logger.log.info("Package (un)installed; OpenTasks provider now available = $tasksInstalled")
|
||||
|
||||
// check all accounts and (de)activate OpenTasks if a CalDAV service is defined
|
||||
val db = AppDatabase.getInstance(context)
|
||||
db.serviceDao().getByType(Service.TYPE_CALDAV).forEach { service ->
|
||||
val account = Account(service.accountName, 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, OpenTasks.authority, 0)
|
||||
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, TaskProvider.ProviderName.OpenTasks.authority) <= 0) {
|
||||
ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 1)
|
||||
ContentResolver.setSyncAutomatically(account, TaskProvider.ProviderName.OpenTasks.authority, true)
|
||||
ContentResolver.addPeriodicSync(account, TaskProvider.ProviderName.OpenTasks.authority, Bundle(), Constants.DEFAULT_SYNC_INTERVAL.toLong())
|
||||
}
|
||||
} else
|
||||
ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 0)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
thread {
|
||||
updateTaskSync(context)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
import android.os.Build
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import java.io.IOException
|
||||
import java.net.InetAddress
|
||||
import java.net.Socket
|
||||
import java.security.GeneralSecurityException
|
||||
import java.util.*
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
class SSLSocketFactoryCompat(trustManager: X509TrustManager): SSLSocketFactory() {
|
||||
|
||||
private var delegate: SSLSocketFactory
|
||||
|
||||
companion object {
|
||||
// Android 5.0+ (API level 21) provides reasonable default settings
|
||||
// but it still allows SSLv3
|
||||
// https://developer.android.com/reference/javax/net/ssl/SSLSocket.html
|
||||
var protocols: Array<String>? = null
|
||||
var cipherSuites: Array<String>? = null
|
||||
init {
|
||||
if (Build.VERSION.SDK_INT >= 23) {
|
||||
// Since Android 6.0 (API level 23),
|
||||
// - TLSv1.1 and TLSv1.2 is enabled by default
|
||||
// - SSLv3 is disabled by default
|
||||
// - all modern ciphers are activated by default
|
||||
protocols = null
|
||||
cipherSuites = null
|
||||
Logger.log.fine("Using device default TLS protocols/ciphers")
|
||||
} else {
|
||||
val socket = SSLSocketFactory.getDefault().createSocket() as SSLSocket?
|
||||
try {
|
||||
socket?.let {
|
||||
/* 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
|
||||
val _protocols = LinkedList<String>()
|
||||
for (protocol in socket.supportedProtocols.filterNot { it.contains("SSL", true) })
|
||||
_protocols += protocol
|
||||
Logger.log.info("Enabling (only) these TLS protocols: ${_protocols.joinToString(", ")}")
|
||||
protocols = _protocols.toTypedArray()
|
||||
|
||||
/* set up reasonable cipher suites */
|
||||
val knownCiphers = arrayOf<String>(
|
||||
// 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",
|
||||
"SSL_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"
|
||||
)
|
||||
val availableCiphers = socket.supportedCipherSuites
|
||||
Logger.log.info("Available cipher suites: ${availableCiphers.joinToString(", ")}")
|
||||
|
||||
/* For maximum security, preferredCiphers should *replace* enabled ciphers (thus
|
||||
* disabling ciphers which are enabled by default, but have become unsecure), but for
|
||||
* the security level of DAVdroid and maximum compatibility, disabling of insecure
|
||||
* ciphers should be a server-side task */
|
||||
|
||||
// for the final set of enabled ciphers, take the ciphers enabled by default, ...
|
||||
val _cipherSuites = LinkedList<String>()
|
||||
_cipherSuites.addAll(socket.enabledCipherSuites)
|
||||
Logger.log.fine("Cipher suites enabled by default: ${_cipherSuites.joinToString(", ")}")
|
||||
// ... add explicitly allowed ciphers ...
|
||||
_cipherSuites.addAll(knownCiphers)
|
||||
// ... and keep only those which are actually available
|
||||
_cipherSuites.retainAll(availableCiphers)
|
||||
|
||||
Logger.log.info("Enabling (only) these TLS ciphers: " + _cipherSuites.joinToString(", "))
|
||||
cipherSuites = _cipherSuites.toTypedArray()
|
||||
}
|
||||
} catch(e: IOException) {
|
||||
Logger.log.severe("Couldn't determine default TLS settings")
|
||||
} finally {
|
||||
socket?.close() // doesn't implement Closeable on all supported Android versions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
init {
|
||||
try {
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(null, arrayOf(trustManager), null)
|
||||
delegate = sslContext.socketFactory
|
||||
} catch (e: GeneralSecurityException) {
|
||||
throw IllegalStateException() // system has no TLS
|
||||
}
|
||||
}
|
||||
|
||||
override fun getDefaultCipherSuites(): Array<String>? = cipherSuites ?: delegate.defaultCipherSuites
|
||||
override fun getSupportedCipherSuites(): Array<String>? = cipherSuites ?: delegate.supportedCipherSuites
|
||||
|
||||
override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket {
|
||||
val ssl = delegate.createSocket(s, host, port, autoClose)
|
||||
if (ssl is SSLSocket)
|
||||
upgradeTLS(ssl)
|
||||
return ssl
|
||||
}
|
||||
|
||||
override fun createSocket(host: String, port: Int): Socket {
|
||||
val ssl = delegate.createSocket(host, port)
|
||||
if (ssl is SSLSocket)
|
||||
upgradeTLS(ssl)
|
||||
return ssl
|
||||
}
|
||||
|
||||
override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket {
|
||||
val ssl = delegate.createSocket(host, port, localHost, localPort)
|
||||
if (ssl is SSLSocket)
|
||||
upgradeTLS(ssl)
|
||||
return ssl
|
||||
}
|
||||
|
||||
override fun createSocket(host: InetAddress, port: Int): Socket {
|
||||
val ssl = delegate.createSocket(host, port)
|
||||
if (ssl is SSLSocket)
|
||||
upgradeTLS(ssl)
|
||||
return ssl
|
||||
}
|
||||
|
||||
override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket {
|
||||
val ssl = delegate.createSocket(address, port, localAddress, localPort)
|
||||
if (ssl is SSLSocket)
|
||||
upgradeTLS(ssl)
|
||||
return ssl
|
||||
}
|
||||
|
||||
|
||||
private fun upgradeTLS(ssl: SSLSocket) {
|
||||
protocols?.let { ssl.enabledProtocols = it }
|
||||
cipherSuites?.let { ssl.enabledCipherSuites = it }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import java.util.logging.LogRecord
|
||||
|
||||
object LogcatHandler: Handler() {
|
||||
|
||||
private const val MAX_LINE_LENGTH = 3000
|
||||
private val MAX_LINE_LENGTH = 3000
|
||||
|
||||
init {
|
||||
formatter = PlainTextFormatter.LOGCAT
|
||||
|
||||
@@ -8,130 +8,97 @@
|
||||
|
||||
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.os.Process
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import android.support.v4.app.NotificationManagerCompat
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.preference.PreferenceManager
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.Settings
|
||||
import at.bitfire.davdroid.ui.AppSettingsActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import org.apache.commons.lang3.time.DateFormatUtils
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.logging.FileHandler
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
@SuppressLint("StaticFieldLeak") // we'll only keep an app context
|
||||
object Logger : SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
object Logger {
|
||||
|
||||
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()
|
||||
@JvmField
|
||||
val log = Logger.getLogger("davdroid")!!
|
||||
init {
|
||||
at.bitfire.dav4android.Constants.log = Logger.getLogger("davdroid.dav4android")
|
||||
at.bitfire.cert4android.Constants.log = Logger.getLogger("davdroid.cert4android")
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
if (key == LOG_TO_FILE) {
|
||||
log.info("Logging settings changed; re-initializing logger")
|
||||
reinitialize()
|
||||
fun reinitLogger(context: Context) {
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val settings = Settings(dbHelper.getReadableDatabase())
|
||||
|
||||
val logToFile = settings.getBoolean(App.LOG_TO_EXTERNAL_STORAGE, false)
|
||||
val logVerbose = logToFile || Log.isLoggable(log.name, Log.DEBUG)
|
||||
|
||||
log.info("Verbose logging: $logVerbose")
|
||||
|
||||
// set logging level according to preferences
|
||||
val rootLogger = 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 = NotificationCompat.Builder(context)
|
||||
builder .setSmallIcon(R.drawable.ic_sd_storage_light)
|
||||
.setLargeIcon(App.getLauncherBitmap(context))
|
||||
.setContentTitle(context.getString(R.string.logging_davdroid_file_logging))
|
||||
.setLocalOnly(true)
|
||||
|
||||
val dir = context.getExternalFilesDir(null)
|
||||
if (dir != null)
|
||||
try {
|
||||
val fileName = File(dir, "davdroid-${Process.myPid()}-${DateFormatUtils.format(System.currentTimeMillis(), "yyyyMMdd-HHmmss")}.txt").toString()
|
||||
log.info("Logging to $fileName")
|
||||
|
||||
val fileHandler = FileHandler(fileName)
|
||||
fileHandler.formatter = PlainTextFormatter.DEFAULT
|
||||
log.addHandler(fileHandler)
|
||||
|
||||
val prefIntent = Intent(context, AppSettingsActivity::class.java)
|
||||
prefIntent.putExtra(AppSettingsActivity.EXTRA_SCROLL_TO, "log_to_external_storage")
|
||||
|
||||
builder .setContentText(dir.path)
|
||||
.setSubText(context.getString(R.string.logging_to_external_storage_warning))
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0, prefIntent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.setStyle(NotificationCompat.BigTextStyle()
|
||||
.bigText(context.getString(R.string.logging_to_external_storage, dir.path)))
|
||||
.setOngoing(true)
|
||||
|
||||
} catch (e: IOException) {
|
||||
log.log(Level.SEVERE, "Couldn't create external log file", e)
|
||||
|
||||
builder .setContentText(context.getString(R.string.logging_couldnt_create_file, e.localizedMessage))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
}
|
||||
else
|
||||
builder.setContentText(context.getString(R.string.logging_no_external_storage))
|
||||
|
||||
nm.notify(Constants.NOTIFICATION_EXTERNAL_FILE_LOGGING, builder.build())
|
||||
} else
|
||||
nm.cancel(Constants.NOTIFICATION_EXTERNAL_FILE_LOGGING)
|
||||
}
|
||||
}
|
||||
|
||||
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_card_notify)
|
||||
.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_notify,
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,21 +8,18 @@
|
||||
|
||||
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
|
||||
val logcat: Boolean
|
||||
): Formatter() {
|
||||
|
||||
companion object {
|
||||
val LOGCAT = PlainTextFormatter(true)
|
||||
val DEFAULT = PlainTextFormatter(false)
|
||||
|
||||
const val MAX_MESSAGE_LENGTH = 20000
|
||||
@JvmField val LOGCAT = PlainTextFormatter(true)
|
||||
@JvmField val DEFAULT = PlainTextFormatter(false)
|
||||
}
|
||||
|
||||
override fun format(r: LogRecord): String {
|
||||
@@ -32,11 +29,13 @@ class PlainTextFormatter private constructor(
|
||||
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("] ")
|
||||
if (r.sourceClassName.replaceFirst("\\$.*", "") != r.loggerName) {
|
||||
val className = shortClassName(r.sourceClassName)
|
||||
if (className != "ical4android.AndroidAppender")
|
||||
builder.append("[").append(className).append("] ")
|
||||
}
|
||||
|
||||
builder.append(StringUtils.abbreviate(r.message, MAX_MESSAGE_LENGTH))
|
||||
builder.append(r.message)
|
||||
|
||||
r.thrown?.let {
|
||||
builder .append("\nEXCEPTION ")
|
||||
@@ -55,7 +54,8 @@ class PlainTextFormatter private constructor(
|
||||
}
|
||||
|
||||
private fun shortClassName(className: String) = className
|
||||
.replace(Regex("^at\\.bitfire\\.(dav|cert4an|dav4an|ical4an|vcard4an)droid\\."), "")
|
||||
.replace("at.bitfire.davdroid.", "")
|
||||
.replace("at.bitfire.", "")
|
||||
.replace(Regex("\\$.*$"), "")
|
||||
|
||||
}
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
import java.util.logging.Handler
|
||||
import java.util.logging.LogRecord
|
||||
import java.util.logging.Handler;
|
||||
import java.util.logging.LogRecord;
|
||||
|
||||
class StringHandler: Handler() {
|
||||
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteException
|
||||
import android.database.sqlite.SQLiteQueryBuilder
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
|
||||
@Suppress("ClassName")
|
||||
@Database(entities = [
|
||||
Service::class,
|
||||
HomeSet::class,
|
||||
Collection::class
|
||||
], version = 7)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class AppDatabase: RoomDatabase() {
|
||||
|
||||
abstract fun serviceDao(): ServiceDao
|
||||
abstract fun homeSetDao(): HomeSetDao
|
||||
abstract fun collectionDao(): CollectionDao
|
||||
|
||||
companion object {
|
||||
|
||||
private var INSTANCE: AppDatabase? = null
|
||||
|
||||
@Synchronized
|
||||
fun getInstance(context: Context): AppDatabase {
|
||||
INSTANCE?.let { return it }
|
||||
|
||||
val db = Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "services.db")
|
||||
.addMigrations(
|
||||
Migration1_2,
|
||||
Migration2_3,
|
||||
Migration3_4,
|
||||
Migration4_5,
|
||||
Migration5_6,
|
||||
Migration6_7
|
||||
)
|
||||
.fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing
|
||||
.build()
|
||||
INSTANCE = db
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun dump(sb: StringBuilder) {
|
||||
val db = openHelper.readableDatabase
|
||||
db.beginTransactionNonExclusive()
|
||||
|
||||
// iterate through all tables
|
||||
db.query(SQLiteQueryBuilder.buildQueryString(false, "sqlite_master", arrayOf("name"), "type='table'", null, null, null, null)).use { cursorTables ->
|
||||
while (cursorTables.moveToNext()) {
|
||||
val table = cursorTables.getString(0)
|
||||
sb.append(table).append("\n")
|
||||
db.query("SELECT * FROM $table").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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// migrations
|
||||
|
||||
object Migration6_7: Migration(6, 7) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN privBind INTEGER NOT NULL DEFAULT 1")
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN displayName TEXT DEFAULT NULL")
|
||||
}
|
||||
}
|
||||
|
||||
object Migration5_6: Migration(5, 6) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
val sql = arrayOf(
|
||||
// migrate "services" to "service": rename columns, make id NOT NULL
|
||||
"CREATE TABLE service(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"accountName TEXT NOT NULL," +
|
||||
"type TEXT NOT NULL," +
|
||||
"principal TEXT DEFAULT NULL" +
|
||||
")",
|
||||
"CREATE UNIQUE INDEX index_service_accountName_type ON service(accountName, type)",
|
||||
"INSERT INTO service(id, accountName, type, principal) SELECT _id, accountName, service, principal FROM services",
|
||||
"DROP TABLE services",
|
||||
|
||||
// migrate "homesets" to "homeset": rename columns, make id NOT NULL
|
||||
"CREATE TABLE homeset(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"serviceId INTEGER NOT NULL," +
|
||||
"url TEXT NOT NULL," +
|
||||
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
|
||||
")",
|
||||
"CREATE UNIQUE INDEX index_homeset_serviceId_url ON homeset(serviceId, url)",
|
||||
"INSERT INTO homeset(id, serviceId, url) SELECT _id, serviceID, url FROM homesets",
|
||||
"DROP TABLE homesets",
|
||||
|
||||
// migrate "collections" to "collection": rename columns, make id NOT NULL
|
||||
"CREATE TABLE collection(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"serviceId INTEGER NOT NULL," +
|
||||
"type TEXT NOT NULL," +
|
||||
"url TEXT NOT NULL," +
|
||||
"privWriteContent INTEGER NOT NULL DEFAULT 1," +
|
||||
"privUnbind INTEGER NOT NULL DEFAULT 1," +
|
||||
"forceReadOnly INTEGER NOT NULL DEFAULT 0," +
|
||||
"displayName TEXT DEFAULT NULL," +
|
||||
"description TEXT DEFAULT NULL," +
|
||||
"color INTEGER DEFAULT NULL," +
|
||||
"timezone TEXT DEFAULT NULL," +
|
||||
"supportsVEVENT INTEGER DEFAULT NULL," +
|
||||
"supportsVTODO INTEGER DEFAULT NULL," +
|
||||
"supportsVJOURNAL INTEGER DEFAULT NULL," +
|
||||
"source TEXT DEFAULT NULL," +
|
||||
"sync INTEGER NOT NULL DEFAULT 0," +
|
||||
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
|
||||
")",
|
||||
"CREATE INDEX index_collection_serviceId_type ON collection(serviceId,type)",
|
||||
"INSERT INTO collection(id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync) " +
|
||||
"SELECT _id, serviceID, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync FROM collections",
|
||||
"DROP TABLE collections"
|
||||
)
|
||||
sql.forEach { db.execSQL(it) }
|
||||
}
|
||||
}
|
||||
|
||||
object Migration4_5: Migration(4, 5) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN privWriteContent INTEGER DEFAULT 0 NOT NULL")
|
||||
db.execSQL("UPDATE collections SET privWriteContent=NOT readOnly")
|
||||
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN privUnbind INTEGER DEFAULT 0 NOT NULL")
|
||||
db.execSQL("UPDATE collections SET privUnbind=NOT readOnly")
|
||||
|
||||
// there's no DROP COLUMN in SQLite, so just keep the "readOnly" column
|
||||
}
|
||||
}
|
||||
|
||||
object Migration3_4: Migration(3, 4) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN forceReadOnly INTEGER DEFAULT 0 NOT NULL")
|
||||
}
|
||||
}
|
||||
|
||||
object Migration2_3: Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
// We don't have access to the context in a Room migration now, so
|
||||
// we will just drop those settings from old DAVx5 versions.
|
||||
Logger.log.warning("Dropping settings distrustSystemCerts and overrideProxy*")
|
||||
|
||||
/*val edit = PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
try {
|
||||
db.query("settings", arrayOf("setting", "value"), null, null, null, null, null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
when (cursor.getString(0)) {
|
||||
"distrustSystemCerts" -> edit.putBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, cursor.getInt(1) != 0)
|
||||
"overrideProxy" -> edit.putBoolean(App.OVERRIDE_PROXY, cursor.getInt(1) != 0)
|
||||
"overrideProxyHost" -> edit.putString(App.OVERRIDE_PROXY_HOST, cursor.getString(1))
|
||||
"overrideProxyPort" -> edit.putInt(App.OVERRIDE_PROXY_PORT, cursor.getInt(1))
|
||||
|
||||
StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED ->
|
||||
edit.putBoolean(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED, cursor.getInt(1) != 0)
|
||||
StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED ->
|
||||
edit.putBoolean(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED, cursor.getInt(1) != 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
db.execSQL("DROP TABLE settings")
|
||||
} finally {
|
||||
edit.apply()
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
object Migration1_2: Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN type TEXT NOT NULL DEFAULT ''")
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN source TEXT DEFAULT NULL")
|
||||
db.execSQL("UPDATE collections SET type=(" +
|
||||
"SELECT CASE service WHEN ? THEN ? ELSE ? END " +
|
||||
"FROM services WHERE _id=collections.serviceID" +
|
||||
")",
|
||||
arrayOf("caldav", "CALENDAR", "ADDRESS_BOOK"))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.room.*
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.dav4jvm.property.*
|
||||
import at.bitfire.davdroid.DavUtils
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
@Entity(tableName = "collection",
|
||||
foreignKeys = [
|
||||
ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE)
|
||||
],
|
||||
indices = [
|
||||
Index("serviceId","type")
|
||||
]
|
||||
)
|
||||
data class Collection(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
override var id: Long = 0,
|
||||
|
||||
var serviceId: Long = 0,
|
||||
|
||||
var type: String,
|
||||
var url: HttpUrl,
|
||||
|
||||
var privWriteContent: Boolean = true,
|
||||
var privUnbind: Boolean = true,
|
||||
var forceReadOnly: Boolean = false,
|
||||
|
||||
var displayName: String? = null,
|
||||
var description: String? = null,
|
||||
|
||||
// CalDAV only
|
||||
var color: Int? = null,
|
||||
|
||||
/** timezone definition (full VTIMEZONE) - not a TZID! **/
|
||||
var timezone: String? = null,
|
||||
|
||||
/** whether the collection supports VEVENT; in case of calendars: null means true */
|
||||
var supportsVEVENT: Boolean? = null,
|
||||
|
||||
/** whether the collection supports VTODO; in case of calendars: null means true */
|
||||
var supportsVTODO: Boolean? = null,
|
||||
|
||||
/** whether the collection supports VJOURNAL; in case of calendars: null means true */
|
||||
var supportsVJOURNAL: Boolean? = null,
|
||||
|
||||
/** Webcal subscription source URL */
|
||||
var source: HttpUrl? = null,
|
||||
|
||||
/** whether this collection has been selected for synchronization */
|
||||
var sync: Boolean = false
|
||||
|
||||
): IdEntity() {
|
||||
|
||||
companion object {
|
||||
|
||||
const val TYPE_ADDRESSBOOK = "ADDRESS_BOOK"
|
||||
const val TYPE_CALENDAR = "CALENDAR"
|
||||
const val TYPE_WEBCAL = "WEBCAL"
|
||||
|
||||
/**
|
||||
* Generates a collection entity from a WebDAV response.
|
||||
* @param dav WebDAV response
|
||||
* @return null if the response doesn't represent a collection
|
||||
*/
|
||||
fun fromDavResponse(dav: Response): Collection? {
|
||||
val url = UrlUtils.withTrailingSlash(dav.href)
|
||||
val type: String = dav[ResourceType::class.java]?.let { resourceType ->
|
||||
when {
|
||||
resourceType.types.contains(ResourceType.ADDRESSBOOK) -> TYPE_ADDRESSBOOK
|
||||
resourceType.types.contains(ResourceType.CALENDAR) -> TYPE_CALENDAR
|
||||
resourceType.types.contains(ResourceType.SUBSCRIBED) -> TYPE_WEBCAL
|
||||
else -> null
|
||||
}
|
||||
} ?: return null
|
||||
|
||||
var privWriteContent = true
|
||||
var privUnbind = true
|
||||
dav[CurrentUserPrivilegeSet::class.java]?.let { privilegeSet ->
|
||||
privWriteContent = privilegeSet.mayWriteContent
|
||||
privUnbind = privilegeSet.mayUnbind
|
||||
}
|
||||
|
||||
var displayName: String? = null
|
||||
dav[DisplayName::class.java]?.let {
|
||||
if (!it.displayName.isNullOrEmpty())
|
||||
displayName = it.displayName
|
||||
}
|
||||
|
||||
var description: String? = null
|
||||
var color: Int? = null
|
||||
var timezone: String? = null
|
||||
var supportsVEVENT: Boolean? = null
|
||||
var supportsVTODO: Boolean? = null
|
||||
var supportsVJOURNAL: Boolean? = null
|
||||
var source: HttpUrl? = null
|
||||
when (type) {
|
||||
TYPE_ADDRESSBOOK -> {
|
||||
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
|
||||
supportsVJOURNAL = true
|
||||
dav[SupportedCalendarComponentSet::class.java]?.let {
|
||||
supportsVEVENT = it.supportsEvents
|
||||
supportsVTODO = it.supportsTasks
|
||||
supportsVJOURNAL = it.supportsJournal
|
||||
}
|
||||
} else { // Type.WEBCAL
|
||||
dav[Source::class.java]?.let { source = it.hrefs.firstOrNull()?.let { rawHref ->
|
||||
val href = rawHref
|
||||
.replace("^webcal://".toRegex(), "http://")
|
||||
.replace("^webcals://".toRegex(), "https://")
|
||||
HttpUrl.parse(href)
|
||||
} }
|
||||
supportsVEVENT = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Collection(
|
||||
type = type,
|
||||
url = url,
|
||||
privWriteContent = privWriteContent,
|
||||
privUnbind = privUnbind,
|
||||
displayName = displayName,
|
||||
description = description,
|
||||
color = color,
|
||||
timezone = timezone,
|
||||
supportsVEVENT = supportsVEVENT,
|
||||
supportsVTODO = supportsVTODO,
|
||||
supportsVJOURNAL = supportsVJOURNAL,
|
||||
source = source
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// non-persistent properties
|
||||
@Ignore
|
||||
var confirmed: Boolean = false
|
||||
|
||||
|
||||
// calculated properties
|
||||
fun title() = displayName ?: DavUtils.lastSegmentOfUrl(url)
|
||||
fun readOnly() = forceReadOnly || !privWriteContent
|
||||
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.paging.DataSource
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface CollectionDao: SyncableDao<Collection> {
|
||||
|
||||
@Query("SELECT * FROM collection WHERE id=:id")
|
||||
fun get(id: Long): Collection?
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId")
|
||||
fun getByService(serviceId: Long): List<Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type")
|
||||
fun getByServiceAndType(serviceId: Long, type: String): List<Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type ORDER BY displayName, url")
|
||||
fun pageByServiceAndType(serviceId: Long, type: String): DataSource.Factory<Int, Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND sync ORDER BY displayName, url")
|
||||
fun getByServiceAndSync(serviceId: Long): List<Collection>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM collection WHERE serviceId=:serviceId AND sync")
|
||||
fun observeHasSyncByService(serviceId: Long): LiveData<Boolean>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND supportsVEVENT AND sync ORDER BY displayName, url")
|
||||
fun getSyncCalendars(serviceId: Long): List<Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND supportsVTODO AND sync ORDER BY displayName, url")
|
||||
fun getSyncTaskLists(serviceId: Long): List<Collection>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertOrReplace(collection: Collection)
|
||||
|
||||
@Insert
|
||||
fun insert(collection: Collection)
|
||||
|
||||
}
|
||||
@@ -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.model
|
||||
|
||||
import android.content.ContentValues
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.property.*
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import java.io.Serializable
|
||||
|
||||
data class CollectionInfo @JvmOverloads constructor(
|
||||
val url: String,
|
||||
|
||||
var id: Long? = null,
|
||||
var serviceID: Long? = null,
|
||||
|
||||
var type: Type? = null,
|
||||
|
||||
var readOnly: 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 selected: Boolean = false,
|
||||
|
||||
// non-persistent properties
|
||||
var confirmed: Boolean = false
|
||||
): Serializable {
|
||||
|
||||
enum class Type {
|
||||
ADDRESS_BOOK,
|
||||
CALENDAR
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
val DAV_PROPERTIES = arrayOf(
|
||||
ResourceType.NAME,
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
DisplayName.NAME,
|
||||
AddressbookDescription.NAME, SupportedAddressData.NAME,
|
||||
CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
||||
constructor(dav: DavResource): this(dav.location.toString()) {
|
||||
(dav.properties[ResourceType.NAME] as ResourceType?)?.let { type ->
|
||||
when {
|
||||
type.types.contains(ResourceType.ADDRESSBOOK) -> this.type = Type.ADDRESS_BOOK
|
||||
type.types.contains(ResourceType.CALENDAR) -> this.type = Type.CALENDAR
|
||||
}
|
||||
}
|
||||
|
||||
(dav.properties[CurrentUserPrivilegeSet.NAME] as CurrentUserPrivilegeSet?)?.let { privilegeSet ->
|
||||
readOnly = !privilegeSet.mayWriteContent
|
||||
}
|
||||
|
||||
(dav.properties[DisplayName.NAME] as DisplayName?)?.let {
|
||||
if (!it.displayName.isNullOrEmpty())
|
||||
displayName = it.displayName
|
||||
}
|
||||
|
||||
when (type) {
|
||||
Type.ADDRESS_BOOK -> {
|
||||
(dav.properties[AddressbookDescription.NAME] as AddressbookDescription?)?.let { description = it.description }
|
||||
}
|
||||
Type.CALENDAR -> {
|
||||
(dav.properties[CalendarDescription.NAME] as CalendarDescription?)?.let { description = it.description }
|
||||
(dav.properties[CalendarColor.NAME] as CalendarColor?)?.let { color = it.color }
|
||||
(dav.properties[CalendarTimezone.NAME] as CalendarTimezone?)?.let { timeZone = it.vTimeZone }
|
||||
|
||||
supportsVEVENT = true
|
||||
supportsVTODO = true
|
||||
(dav.properties[SupportedCalendarComponentSet.NAME] as SupportedCalendarComponentSet?)?.let {
|
||||
supportsVEVENT = it.supportsEvents
|
||||
supportsVTODO = it.supportsTasks
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
constructor(values: ContentValues): this(values.getAsString(Collections.URL)) {
|
||||
id = values.getAsLong(Collections.ID)
|
||||
serviceID = values.getAsLong(Collections.SERVICE_ID)
|
||||
|
||||
readOnly = values.getAsInteger(Collections.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
|
||||
|
||||
selected = values.getAsInteger(Collections.SYNC) != 0
|
||||
}
|
||||
|
||||
fun toDB(): ContentValues {
|
||||
val values = ContentValues()
|
||||
// Collections.SERVICE_ID is never changed
|
||||
|
||||
values.put(Collections.URL, url)
|
||||
values.put(Collections.READ_ONLY, if (readOnly) 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.SYNC, if (selected) 1 else 0)
|
||||
return values
|
||||
}
|
||||
|
||||
|
||||
private fun getAsBooleanOrNull(values: ContentValues, field: String): Boolean? {
|
||||
val i = values.getAsInteger(field)
|
||||
return if (i == null)
|
||||
null
|
||||
else
|
||||
(i != 0)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
class Converters {
|
||||
|
||||
@TypeConverter
|
||||
fun httpUrlToString(url: HttpUrl?) =
|
||||
url?.toString()
|
||||
|
||||
@TypeConverter
|
||||
fun stringToHttpUrl(url: String?): HttpUrl? =
|
||||
url?.let { HttpUrl.parse(it) }
|
||||
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* 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,30 +0,0 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import java.util.logging.Level
|
||||
|
||||
class DaoTools<T: IdEntity>(dao: SyncableDao<T>): SyncableDao<T> by dao {
|
||||
|
||||
fun <K> syncAll(allOld: List<T>, allNew: Map<K,T>, selectKey: (T) -> K) {
|
||||
Logger.log.log(Level.FINE, "Syncing tables", arrayOf(allOld, allNew))
|
||||
val remainingNew = allNew.toMutableMap()
|
||||
allOld.forEach { old ->
|
||||
val key = selectKey(old)
|
||||
val matchingNew = remainingNew[key]
|
||||
if (matchingNew != null) {
|
||||
// keep this old item, but maybe update it
|
||||
matchingNew.id = old.id // identity is proven by key
|
||||
if (matchingNew != old)
|
||||
update(matchingNew)
|
||||
|
||||
// remove from remainingNew
|
||||
remainingNew -= key
|
||||
} else {
|
||||
// this old item is not present anymore, delete it
|
||||
delete(old)
|
||||
}
|
||||
}
|
||||
insert(remainingNew.values.toList())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
@Entity(tableName = "homeset",
|
||||
foreignKeys = [
|
||||
ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE)
|
||||
],
|
||||
indices = [
|
||||
// index by service; no duplicate URLs per service
|
||||
Index("serviceId", "url", unique = true)
|
||||
]
|
||||
)
|
||||
data class HomeSet(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
override var id: Long,
|
||||
|
||||
var serviceId: Long,
|
||||
var url: HttpUrl,
|
||||
|
||||
var privBind: Boolean = true,
|
||||
|
||||
var displayName: String? = null
|
||||
): IdEntity()
|
||||
@@ -1,21 +0,0 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface HomeSetDao: SyncableDao<HomeSet> {
|
||||
|
||||
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId")
|
||||
fun getByService(serviceId: Long): List<HomeSet>
|
||||
|
||||
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND privBind")
|
||||
fun getBindableByService(serviceId: Long): List<HomeSet>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertOrReplace(homeSet: HomeSet): Long
|
||||
|
||||
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
abstract class IdEntity {
|
||||
abstract var id: Long
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
@Entity(tableName = "service",
|
||||
indices = [
|
||||
// only one service per type and account
|
||||
Index("accountName", "type", unique = true)
|
||||
])
|
||||
data class Service(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
var id: Long,
|
||||
|
||||
var accountName: String,
|
||||
var type: String,
|
||||
|
||||
var principal: HttpUrl?
|
||||
) {
|
||||
|
||||
companion object {
|
||||
const val TYPE_CALDAV = "caldav"
|
||||
const val TYPE_CARDDAV = "carddav"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* 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 at.bitfire.davdroid.log.Logger
|
||||
import java.io.Closeable
|
||||
|
||||
class ServiceDB {
|
||||
|
||||
object Settings {
|
||||
@JvmField val _TABLE = "settings"
|
||||
@JvmField val NAME = "setting"
|
||||
@JvmField val VALUE = "value"
|
||||
}
|
||||
|
||||
object Services {
|
||||
@JvmField val _TABLE = "services"
|
||||
@JvmField val ID = "_id"
|
||||
@JvmField val ACCOUNT_NAME = "accountName"
|
||||
@JvmField val SERVICE = "service"
|
||||
@JvmField val PRINCIPAL = "principal"
|
||||
|
||||
// allowed values for SERVICE column
|
||||
@JvmField val SERVICE_CALDAV = "caldav"
|
||||
@JvmField val SERVICE_CARDDAV = "carddav"
|
||||
}
|
||||
|
||||
object HomeSets {
|
||||
@JvmField val _TABLE = "homesets"
|
||||
@JvmField val ID = "_id"
|
||||
@JvmField val SERVICE_ID = "serviceID"
|
||||
@JvmField val URL = "url"
|
||||
}
|
||||
|
||||
object Collections {
|
||||
@JvmField val _TABLE = "collections"
|
||||
@JvmField val ID = "_id"
|
||||
@JvmField val SERVICE_ID = "serviceID"
|
||||
@JvmField val URL = "url"
|
||||
@JvmField val READ_ONLY = "readOnly"
|
||||
@JvmField val DISPLAY_NAME = "displayName"
|
||||
@JvmField val DESCRIPTION = "description"
|
||||
@JvmField val COLOR = "color"
|
||||
@JvmField val TIME_ZONE = "timezone"
|
||||
@JvmField val SUPPORTS_VEVENT = "supportsVEVENT"
|
||||
@JvmField val SUPPORTS_VTODO = "supportsVTODO"
|
||||
@JvmField val SYNC = "sync"
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmStatic
|
||||
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(
|
||||
context: Context
|
||||
): SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION), Closeable {
|
||||
|
||||
companion object {
|
||||
val DATABASE_NAME = "services.db"
|
||||
val DATABASE_VERSION = 1
|
||||
}
|
||||
|
||||
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 ${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 fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
// no different versions yet
|
||||
}
|
||||
|
||||
|
||||
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 .. cols-1)
|
||||
sb .append(" ")
|
||||
.append(cursor.getColumnName(i))
|
||||
.append(" |")
|
||||
sb.append("\n")
|
||||
|
||||
// print rows
|
||||
while (cursor.moveToNext()) {
|
||||
sb.append("\t| ")
|
||||
for (i in 0 .. cols-1) {
|
||||
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,35 +0,0 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface ServiceDao {
|
||||
|
||||
@Query("SELECT * FROM service WHERE accountName=:accountName AND type=:type")
|
||||
fun getByAccountAndType(accountName: String, type: String): Service?
|
||||
|
||||
@Query("SELECT id FROM service WHERE accountName=:accountName AND type=:type")
|
||||
fun getIdByAccountAndType(accountName: String, type: String): Long?
|
||||
|
||||
@Query("SELECT * FROM service WHERE id=:id")
|
||||
fun get(id: Long): Service?
|
||||
|
||||
@Query("SELECT * FROM service WHERE type=:type")
|
||||
fun getByType(type: String): List<Service>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertOrReplace(service: Service): Long
|
||||
|
||||
@Query("DELETE FROM service")
|
||||
fun deleteAll()
|
||||
|
||||
@Query("DELETE FROM service WHERE accountName NOT IN (:accountNames)")
|
||||
fun deleteExceptAccounts(accountNames: Array<String>)
|
||||
|
||||
@Query("UPDATE service SET accountName=:newName WHERE accountName=:oldName")
|
||||
fun renameAccount(oldName: String, newName: String)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* 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.BroadcastReceiver
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import at.bitfire.davdroid.CustomCertificates
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
|
||||
class Settings(
|
||||
val db: SQLiteDatabase
|
||||
) {
|
||||
|
||||
fun getBoolean(name: String, defaultValue: Boolean): Boolean {
|
||||
db.query(ServiceDB.Settings._TABLE, arrayOf(ServiceDB.Settings.VALUE),
|
||||
"${ServiceDB.Settings.NAME}=?", arrayOf(name), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext() && !cursor.isNull(0))
|
||||
return cursor.getInt(0) != 0
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
fun putBoolean(name: String, value: Boolean) {
|
||||
val values = ContentValues(2)
|
||||
values.put(ServiceDB.Settings.NAME, name)
|
||||
values.put(ServiceDB.Settings.VALUE, if (value) 1 else 0)
|
||||
db.insertWithOnConflict(ServiceDB.Settings._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
}
|
||||
|
||||
|
||||
fun getInt(name: String, defaultValue: Int): Int {
|
||||
db.query(ServiceDB.Settings._TABLE, arrayOf(ServiceDB.Settings.VALUE),
|
||||
"${ServiceDB.Settings.NAME}=?", arrayOf(name), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext() && !cursor.isNull(0))
|
||||
return if (cursor.isNull(0)) defaultValue else cursor.getInt(0)
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
fun putInt(name: String, value: Int?) {
|
||||
val values = ContentValues(2)
|
||||
values.put(ServiceDB.Settings.NAME, name)
|
||||
values.put(ServiceDB.Settings.VALUE, value)
|
||||
db.insertWithOnConflict(ServiceDB.Settings._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
}
|
||||
|
||||
|
||||
fun getString(name: String, defaultValue: String?): String? {
|
||||
db.query(ServiceDB.Settings._TABLE, arrayOf(ServiceDB.Settings.VALUE),
|
||||
"${ServiceDB.Settings.NAME}=?", arrayOf(name), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return cursor.getString(0)
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
fun putString(name: String, value: String?) {
|
||||
val values = ContentValues(2)
|
||||
values.put(ServiceDB.Settings.NAME, name)
|
||||
values.put(ServiceDB.Settings.VALUE, value)
|
||||
db.insertWithOnConflict(ServiceDB.Settings._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
}
|
||||
|
||||
|
||||
fun remove(name: String) {
|
||||
db.delete(ServiceDB.Settings._TABLE, "${ServiceDB.Settings.NAME}=?", arrayOf(name))
|
||||
}
|
||||
|
||||
|
||||
class ReinitSettingsReceiver: BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
@JvmField val ACTION_REINIT_SETTINGS = "at.bitfire.davdroid.REINIT_SETTINGS"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Logger.log.info("Received broadcast: re-initializing settings (logger/cert manager)")
|
||||
|
||||
CustomCertificates.reinitCertManager(context)
|
||||
Logger.reinitLogger(context)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
* 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,18 +0,0 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Update
|
||||
|
||||
interface SyncableDao<T: IdEntity> {
|
||||
|
||||
@Insert
|
||||
fun insert(items: List<T>)
|
||||
|
||||
@Update
|
||||
fun update(item: T)
|
||||
|
||||
@Delete
|
||||
fun delete(item: T)
|
||||
|
||||
}
|
||||
@@ -6,16 +6,23 @@
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model
|
||||
package at.bitfire.davdroid.model;
|
||||
|
||||
import android.provider.ContactsContract.RawContacts
|
||||
import android.provider.ContactsContract.RawContacts;
|
||||
|
||||
object UnknownProperties {
|
||||
|
||||
const val CONTENT_ITEM_TYPE = "x.davdroid/unknown-properties"
|
||||
@JvmField
|
||||
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
|
||||
|
||||
@JvmField
|
||||
val MIMETYPE = RawContacts.Data.MIMETYPE
|
||||
|
||||
@JvmField
|
||||
val RAW_CONTACT_ID = RawContacts.Data.RAW_CONTACT_ID
|
||||
|
||||
@JvmField
|
||||
val UNKNOWN_PROPERTIES = RawContacts.Data.DATA1
|
||||
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* 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()
|
||||
|
||||
}
|
||||
@@ -18,85 +18,73 @@ import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import android.provider.ContactsContract.Groups
|
||||
import android.provider.ContactsContract.RawContacts
|
||||
import android.util.Base64
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.DavUtils
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.vcard4android.*
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.FileNotFoundException
|
||||
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> {
|
||||
): AndroidAddressBook<LocalContact, LocalGroup>(account, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalResource> {
|
||||
|
||||
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"
|
||||
val USER_DATA_MAIN_ACCOUNT_TYPE = "real_account_type"
|
||||
val USER_DATA_MAIN_ACCOUNT_NAME = "real_account_name"
|
||||
val USER_DATA_URL = "url"
|
||||
val USER_DATA_CTAG = "ctag"
|
||||
|
||||
fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, info: Collection): LocalAddressBook {
|
||||
@JvmStatic
|
||||
@Throws(ContactsStorageException::class)
|
||||
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))
|
||||
val userData = initialUserData(mainAccount, info.url.toString())
|
||||
Logger.log.log(Level.INFO, "Creating local address book $account", userData)
|
||||
if (!accountManager.addAccountExplicitly(account, null, userData))
|
||||
throw IllegalStateException("Couldn't create address book account")
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N)
|
||||
// Android < 7 seems to lose the initial user data sometimes, so set it a second time
|
||||
// https://forums.bitfire.at/post/11644
|
||||
userData.keySet().forEach { key ->
|
||||
accountManager.setUserData(account, key, userData.getString(key))
|
||||
}
|
||||
val account = Account(accountName(mainAccount, info), App.addressBookAccountType)
|
||||
if (!accountManager.addAccountExplicitly(account, null, initialUserData(mainAccount, info.url)))
|
||||
throw ContactsStorageException("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()
|
||||
@JvmStatic
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun find(context: Context, provider: ContentProviderClient, mainAccount: Account?): List<LocalAddressBook> {
|
||||
val accountManager = AccountManager.get(context)
|
||||
|
||||
fun accountName(mainAccount: Account, info: Collection): String {
|
||||
val result = LinkedList<LocalAddressBook>()
|
||||
accountManager.getAccountsByType(App.addressBookAccountType)
|
||||
.map { LocalAddressBook(context, it, provider) }
|
||||
.filter { mainAccount == null || it.getMainAccount() == mainAccount }
|
||||
.forEach { result += it }
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
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)")
|
||||
val sb = StringBuilder(if (info.displayName.isNullOrEmpty()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
|
||||
sb .append(" (")
|
||||
.append(mainAccount.name)
|
||||
.append(" ")
|
||||
.append(hash)
|
||||
.append(")")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun initialUserData(mainAccount: Account, url: String): Bundle {
|
||||
val bundle = Bundle(3)
|
||||
bundle.putString(USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
|
||||
@@ -105,174 +93,87 @@ class LocalAddressBook(
|
||||
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.
|
||||
* Whether contact groups (LocalGroup resources) are included in query results for
|
||||
* {@link #getAll()}, {@link #getDeleted()}, {@link #getDirty()} and
|
||||
* {@link #getWithoutFileName()}.
|
||||
*/
|
||||
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("No main account assigned to address book account")
|
||||
}
|
||||
}
|
||||
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: Collection) {
|
||||
val newAccountName = accountName(mainAccount, info)
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun update(info: CollectionInfo) {
|
||||
val newAccountName = accountName(getMainAccount(), 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)
|
||||
val future = accountManager.renameAccount(account, newAccountName, {
|
||||
try {
|
||||
// update raw contacts to new account name
|
||||
provider?.let { provider ->
|
||||
val values = ContentValues(1)
|
||||
values.put(RawContacts.ACCOUNT_NAME, newAccountName)
|
||||
provider.update(syncAdapterURI(RawContacts.CONTENT_URI), values, "${RawContacts.ACCOUNT_NAME}=?", arrayOf(account.name))
|
||||
}
|
||||
} catch(e: RemoteException) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't re-assign contacts to new account name", e)
|
||||
}
|
||||
}, 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)
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
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)
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= 22)
|
||||
accountManager.removeAccount(account, null, null, null)
|
||||
else
|
||||
@Suppress("deprecation")
|
||||
accountManager.removeAccount(account, null, null)
|
||||
} catch(e: Exception) {
|
||||
throw ContactsStorageException("Couldn't remove address book", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* 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
|
||||
@Throws(ContactsStorageException::class, FileNotFoundException::class)
|
||||
fun findContactByUID(uid: String): LocalContact {
|
||||
val contacts = queryContacts("${AndroidContact.COLUMN_UID}=?", arrayOf(uid))
|
||||
if (contacts.isEmpty())
|
||||
throw FileNotFoundException()
|
||||
return contacts.first()
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun getAll(): List<LocalResource> {
|
||||
val all = LinkedList<LocalResource>()
|
||||
all.addAll(queryContacts(null, null))
|
||||
if (includeGroups)
|
||||
all.addAll(queryGroups(null, null))
|
||||
return all
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun getDeleted(): List<LocalResource> {
|
||||
val deleted = LinkedList<LocalResource>()
|
||||
deleted.addAll(getDeletedContacts())
|
||||
if (includeGroups)
|
||||
deleted.addAll(getDeletedGroups())
|
||||
return deleted
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries all contacts with DIRTY flag and checks whether their data checksum has changed, i.e.
|
||||
@@ -280,44 +181,89 @@ class LocalAddressBook(
|
||||
* 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
|
||||
*/
|
||||
@Throws(ContactsStorageException::class)
|
||||
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++
|
||||
for (contact in getDirtyContacts())
|
||||
try {
|
||||
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++
|
||||
}
|
||||
} catch(e: FileNotFoundException) {
|
||||
throw ContactsStorageException("Couldn't calculate hash code", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (includeGroups)
|
||||
reallyDirty += findDirtyGroups().size
|
||||
reallyDirty += getDirtyGroups().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)
|
||||
}
|
||||
/**
|
||||
* Returns an array of local contacts/groups which have been changed locally (DIRTY != 0).
|
||||
*/
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun getDirty(): List<LocalResource> {
|
||||
val dirty = LinkedList<LocalResource>()
|
||||
dirty.addAll(getDirtyContacts())
|
||||
if (includeGroups)
|
||||
dirty.addAll(getDirtyGroups())
|
||||
return dirty
|
||||
}
|
||||
|
||||
return ids.map { findContactByID(it) }
|
||||
/**
|
||||
* Returns an array of local contacts which don't have a file name yet.
|
||||
*/
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun getWithoutFileName(): List<LocalResource> {
|
||||
val nameless = LinkedList<LocalResource>()
|
||||
nameless.addAll(queryContacts("${AndroidContact.COLUMN_FILENAME} IS NULL", null))
|
||||
if (includeGroups)
|
||||
nameless.addAll(queryGroups("${AndroidGroup.COLUMN_FILENAME} IS NULL", null))
|
||||
return nameless
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun getDeletedContacts() = queryContacts("${RawContacts.DELETED} != 0", null)
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun getDirtyContacts() = queryContacts("${RawContacts.DIRTY} != 0", null)
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun getDeletedGroups() = queryGroups("${Groups.DELETED} != 0", null)
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun getDirtyGroups() = queryGroups("${Groups.DIRTY} != 0", null)
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun getByGroupMembership(groupID: Long): List<LocalContact> {
|
||||
try {
|
||||
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 { id -> LocalContact(this, id, null, null) }
|
||||
} catch (e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't query contacts", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -326,23 +272,29 @@ class LocalAddressBook(
|
||||
/**
|
||||
* 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
|
||||
* @param title title of the group to look for
|
||||
* @return id of the group with given title
|
||||
* @throws ContactsStorageException on contact provider errors
|
||||
*/
|
||||
@Throws(ContactsStorageException::class)
|
||||
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)
|
||||
}
|
||||
try {
|
||||
provider!!.query(syncAdapterURI(Groups.CONTENT_URI), arrayOf(Groups._ID),
|
||||
"${Groups.TITLE}=?", arrayOf(title), null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return cursor.getLong(0)
|
||||
}
|
||||
|
||||
val values = ContentValues(1)
|
||||
values.put(Groups.TITLE, title)
|
||||
val uri = provider.insert(syncAdapterURI(Groups.CONTENT_URI), values) ?: throw RemoteException("Couldn't create contact group")
|
||||
return ContentUris.parseId(uri)
|
||||
val values = ContentValues(1)
|
||||
values.put(Groups.TITLE, title)
|
||||
val uri = provider.insert(syncAdapterURI(Groups.CONTENT_URI), values)
|
||||
return ContentUris.parseId(uri)
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't find local contact group", e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun removeEmptyGroups() {
|
||||
// find groups without members
|
||||
/** should be done using {@link Groups.SUMMARY_COUNT}, but it's not implemented in Android yet */
|
||||
@@ -352,4 +304,54 @@ class LocalAddressBook(
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun removeGroups() {
|
||||
try {
|
||||
provider!!.delete(syncAdapterURI(Groups.CONTENT_URI), null, null)
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't remove all groups", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// SETTINGS
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun getMainAccount(): Account {
|
||||
val accountManager = AccountManager.get(context)
|
||||
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 ContactsStorageException("Address book doesn't exist anymore")
|
||||
}
|
||||
|
||||
fun setMainAccount(mainAccount: Account) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
|
||||
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE, mainAccount.type)
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun getURL(): String {
|
||||
val accountManager = AccountManager.get(context)
|
||||
return accountManager.getUserData(account, USER_DATA_URL) ?: throw ContactsStorageException("Address book has no URL")
|
||||
}
|
||||
|
||||
fun setURL(url: String) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.setUserData(account, USER_DATA_URL, url)
|
||||
}
|
||||
|
||||
override fun getCTag(): String? {
|
||||
val accountManager = AccountManager.get(context)
|
||||
return accountManager.getUserData(account, USER_DATA_CTAG)
|
||||
}
|
||||
|
||||
override fun setCTag(cTag: String?) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.setUserData(account, USER_DATA_CTAG, cTag)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -14,16 +14,14 @@ import android.content.ContentProviderOperation
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
import android.os.RemoteException
|
||||
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.Collection
|
||||
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 at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.ical4android.*
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
@@ -35,9 +33,19 @@ class LocalCalendar private constructor(
|
||||
|
||||
companion object {
|
||||
|
||||
private const val COLUMN_SYNC_STATE = Calendars.CAL_SYNC1
|
||||
val defaultColor = 0xFF8bc34a.toInt() // light green 500
|
||||
|
||||
fun create(account: Account, provider: ContentProviderClient, info: Collection): Uri {
|
||||
val COLUMN_CTAG = Calendars.CAL_SYNC1
|
||||
|
||||
val BASE_INFO_COLUMNS = arrayOf(
|
||||
Events._ID,
|
||||
Events._SYNC_ID,
|
||||
LocalEvent.COLUMN_ETAG
|
||||
)
|
||||
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
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.
|
||||
@@ -48,25 +56,31 @@ class LocalCalendar private constructor(
|
||||
// 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)
|
||||
val uri = create(account, provider, values)
|
||||
|
||||
// create calendar colors
|
||||
AndroidCalendar.insertColors(provider, account)
|
||||
|
||||
return uri
|
||||
}
|
||||
|
||||
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
|
||||
private fun valuesFromCollectionInfo(info: CollectionInfo, withColor: Boolean): ContentValues {
|
||||
val values = ContentValues()
|
||||
values.put(Calendars.NAME, info.url.toString())
|
||||
values.put(Calendars.NAME, info.url)
|
||||
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)
|
||||
values.put(Calendars.CALENDAR_COLOR, info.color ?: defaultColor)
|
||||
|
||||
if (info.privWriteContent && !info.forceReadOnly) {
|
||||
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)
|
||||
} else
|
||||
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ)
|
||||
}
|
||||
|
||||
info.timezone?.let { tzData ->
|
||||
info.timeZone?.let { tzData ->
|
||||
try {
|
||||
val timeZone = DateUtils.parseVTimeZone(tzData)
|
||||
timeZone.timeZoneId?.let { tzId ->
|
||||
@@ -76,38 +90,36 @@ class LocalCalendar private constructor(
|
||||
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_REMINDERS, Reminders.METHOD_ALERT)
|
||||
values.put(Calendars.ALLOWED_AVAILABILITY, "${Reminders.AVAILABILITY_TENTATIVE},${Reminders.AVAILABILITY_FREE},${Reminders.AVAILABILITY_BUSY}")
|
||||
values.put(Calendars.ALLOWED_ATTENDEE_TYPES, "${Attendees.TYPE_OPTIONAL},${Attendees.TYPE_REQUIRED},${Attendees.TYPE_RESOURCE}")
|
||||
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)
|
||||
}
|
||||
override fun eventBaseInfoColumns() = BASE_INFO_COLUMNS
|
||||
|
||||
|
||||
fun update(info: Collection, updateColor: Boolean) =
|
||||
@Throws(CalendarStorageException::class)
|
||||
fun update(info: CollectionInfo, updateColor: Boolean) =
|
||||
update(valuesFromCollectionInfo(info, updateColor))
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun getAll(): List<LocalEvent> =
|
||||
queryEvents("${Events.ORIGINAL_ID} IS NULL", null)
|
||||
|
||||
override fun findDeleted() =
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun getDeleted() =
|
||||
queryEvents("${Events.DELETED}!=0 AND ${Events.ORIGINAL_ID} IS NULL", null)
|
||||
|
||||
override fun findDirty(): List<LocalEvent> {
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun getWithoutFileName() =
|
||||
queryEvents("${Events._SYNC_ID} IS NULL AND ${Events.ORIGINAL_ID} IS NULL", null)
|
||||
|
||||
@Throws(FileNotFoundException::class, CalendarStorageException::class)
|
||||
override fun getDirty(): List<LocalEvent> {
|
||||
val dirty = LinkedList<LocalEvent>()
|
||||
|
||||
// get dirty events which are required to have an increased SEQUENCE value
|
||||
@@ -124,46 +136,50 @@ class LocalCalendar private constructor(
|
||||
return dirty
|
||||
}
|
||||
|
||||
override fun findByName(name: String) =
|
||||
queryEvents("${Events._SYNC_ID}=?", arrayOf(name)).firstOrNull()
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun getCTag(): String? =
|
||||
try {
|
||||
provider.query(calendarSyncURI(), arrayOf(COLUMN_CTAG), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return cursor.getString(0)
|
||||
}
|
||||
null
|
||||
} catch (e: RemoteException) {
|
||||
throw CalendarStorageException("Couldn't read local (last known) CTag", e)
|
||||
}
|
||||
|
||||
|
||||
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()))
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun setCTag(cTag: String?) {
|
||||
try {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_CTAG, cTag)
|
||||
provider.update(calendarSyncURI(), values, null, null)
|
||||
} catch (e: RemoteException) {
|
||||
throw CalendarStorageException("Couldn't write local (last known) CTag", e)
|
||||
}
|
||||
}
|
||||
|
||||
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()))
|
||||
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
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)
|
||||
try {
|
||||
// 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.DELETED}!=0 AND ${Events.ORIGINAL_ID} IS NOT NULL", null, null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
Logger.log.fine("Found deleted exception, removing; then re-scheduling original event")
|
||||
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)
|
||||
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
|
||||
// get original event's SEQUENCE
|
||||
provider.query(
|
||||
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)),
|
||||
arrayOf(LocalEvent.COLUMN_SEQUENCE),
|
||||
null, null, null)?.use { cursor2 ->
|
||||
val originalSequence = if (cursor2.isNull(0)) 0 else cursor2.getInt(0)
|
||||
|
||||
// re-schedule original event and set it to DIRTY
|
||||
@@ -173,43 +189,44 @@ class LocalCalendar private constructor(
|
||||
.withValue(Events.DIRTY, 1)
|
||||
))
|
||||
}
|
||||
|
||||
// remove exception
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newDelete(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)))
|
||||
))
|
||||
batch.commit()
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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.DIRTY}!=0 AND ${Events.ORIGINAL_ID} IS NOT NULL", null, 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()
|
||||
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()
|
||||
}
|
||||
}
|
||||
} catch (e: RemoteException) {
|
||||
throw CalendarStorageException("Couldn't process locally modified exception", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,4 +238,4 @@ class LocalCalendar private constructor(
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -8,41 +8,28 @@
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.provider.CalendarContract.Events
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.ical4android.CalendarStorageException
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
interface LocalCollection<out T: LocalResource<*>> {
|
||||
interface LocalCollection<out T: LocalResource> {
|
||||
|
||||
/** collection title (used for user notifications etc.) **/
|
||||
val title: String
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun getDeleted(): List<T>
|
||||
|
||||
var lastSyncState: SyncState?
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun getWithoutFileName(): List<T>
|
||||
|
||||
fun findDeleted(): List<T>
|
||||
fun findDirty(): List<T>
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class, FileNotFoundException::class)
|
||||
fun getDirty(): List<T>
|
||||
|
||||
fun findByName(name: String): T?
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun getAll(): List<T>
|
||||
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun getCTag(): String?
|
||||
|
||||
/**
|
||||
* 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
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun setCTag(cTag: String?)
|
||||
|
||||
}
|
||||
@@ -23,110 +23,118 @@ import ezvcard.Ezvcard
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.*
|
||||
|
||||
class LocalContact: AndroidContact, LocalAddress {
|
||||
class LocalContact: AndroidContact, LocalResource {
|
||||
|
||||
companion object {
|
||||
|
||||
init {
|
||||
Contact.productID = "+//IDN bitfire.at//${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ez-vcard/" + Ezvcard.VERSION
|
||||
Contact.productID = "+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ez-vcard/" + Ezvcard.VERSION
|
||||
}
|
||||
|
||||
const val COLUMN_FLAGS = ContactsContract.RawContacts.SYNC4
|
||||
const val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3
|
||||
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,*>, id: Long, fileName: String?, eTag: String?):
|
||||
super(addressBook, id, fileName, eTag)
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<LocalContact,*>, contact: Contact, fileName: String?, eTag: String?, flags: Int)
|
||||
: super(addressBook, contact, fileName, eTag) {
|
||||
this.flags = flags
|
||||
}
|
||||
constructor(addressBook: AndroidAddressBook<LocalContact,*>, contact: Contact, fileName: String?, eTag: String?):
|
||||
super(addressBook, contact, fileName, eTag)
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun resetDirty() {
|
||||
val values = ContentValues(1)
|
||||
values.put(ContactsContract.RawContacts.DIRTY, 0)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
try {
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't clear dirty flag", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun clearDirty(eTag: String?) {
|
||||
try {
|
||||
val values = ContentValues(3)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(ContactsContract.RawContacts.DIRTY, 0)
|
||||
|
||||
this.flags = flags
|
||||
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
|
||||
} catch(e: Exception) {
|
||||
throw ContactsStorageException("Couldn't clear dirty flag", e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun prepareForUpload() {
|
||||
try {
|
||||
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
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't update UID", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun populateData(mimeType: String, row: ContentValues) {
|
||||
when (mimeType) {
|
||||
CachedGroupMembership.CONTENT_ITEM_TYPE ->
|
||||
cachedGroupMemberships += row.getAsLong(CachedGroupMembership.GROUP_ID)
|
||||
cachedGroupMemberships.add(row.getAsLong(CachedGroupMembership.GROUP_ID))
|
||||
GroupMembership.CONTENT_ITEM_TYPE ->
|
||||
groupMemberships += row.getAsLong(GroupMembership.GROUP_ROW_ID)
|
||||
groupMemberships.add(row.getAsLong(GroupMembership.GROUP_ROW_ID))
|
||||
UnknownProperties.CONTENT_ITEM_TYPE ->
|
||||
contact!!.unknownProperties = row.getAsString(UnknownProperties.UNKNOWN_PROPERTIES)
|
||||
try {
|
||||
contact!!.unknownProperties = row.getAsString(UnknownProperties.UNKNOWN_PROPERTIES)
|
||||
} catch(e: FileNotFoundException) {
|
||||
throw ContactsStorageException("Couldn't fetch data rows", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
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)
|
||||
try {
|
||||
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)
|
||||
}
|
||||
builder .withValue(UnknownProperties.MIMETYPE, UnknownProperties.CONTENT_ITEM_TYPE)
|
||||
.withValue(UnknownProperties.UNKNOWN_PROPERTIES, unknownProperties)
|
||||
batch.enqueue(op)
|
||||
} catch(e: FileNotFoundException) {
|
||||
throw ContactsStorageException("Couldn't insert data rows", e)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +143,7 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
* Attention: re-reads {@link #contact} from the database, discarding all changes in memory
|
||||
* @return hash code of contact data (including group memberships)
|
||||
*/
|
||||
@Throws(FileNotFoundException::class, ContactsStorageException::class)
|
||||
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")
|
||||
@@ -149,34 +158,44 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
return dataHash xor groupHash
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
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)
|
||||
try {
|
||||
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))
|
||||
if (batch == null)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
else {
|
||||
val builder = ContentProviderOperation
|
||||
.newUpdate(rawContactSyncURI())
|
||||
.withValues(values)
|
||||
batch.enqueue(BatchOperation.Operation(builder))
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
throw ContactsStorageException("Couldn't store contact checksum", e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
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)
|
||||
try {
|
||||
addressBook.provider!!.query(rawContactSyncURI(), arrayOf(COLUMN_HASHCODE), null, null, null)?.use { c ->
|
||||
if (c.moveToNext() && !c.isNull(0))
|
||||
return c.getInt(0)
|
||||
}
|
||||
return 0
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Could't read last hash code", e)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
@@ -187,7 +206,7 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
.withValue(GroupMembership.RAW_CONTACT_ID, id)
|
||||
.withValue(GroupMembership.GROUP_ROW_ID, groupID)
|
||||
))
|
||||
groupMemberships += groupID
|
||||
groupMemberships.add(groupID)
|
||||
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newInsert(dataSyncURI())
|
||||
@@ -196,7 +215,7 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
.withValue(CachedGroupMembership.GROUP_ID, groupID)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
cachedGroupMemberships += groupID
|
||||
cachedGroupMemberships.add(groupID)
|
||||
}
|
||||
|
||||
fun removeGroupMemberships(batch: BatchOperation) {
|
||||
@@ -214,12 +233,13 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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 FileNotFoundException if the current contact can't be found
|
||||
* @throws RemoteException on contacts provider errors
|
||||
* @throws ContactsStorageException on contact provider errors
|
||||
* @throws FileNotFoundException if the current contact can't be found
|
||||
*/
|
||||
@Throws(FileNotFoundException::class, ContactsStorageException::class)
|
||||
fun getCachedGroupMemberships(): Set<Long> {
|
||||
contact
|
||||
return cachedGroupMemberships
|
||||
@@ -228,26 +248,26 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
/**
|
||||
* 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
|
||||
* @throws ContactsStorageException on contact provider errors
|
||||
* @throws FileNotFoundException if the current contact can't be found
|
||||
*/
|
||||
@Throws(FileNotFoundException::class, ContactsStorageException::class)
|
||||
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)
|
||||
|
||||
override fun newInstance(addressBook: AndroidAddressBook<LocalContact, *>, id: Long, fileName: String?, eTag: String?) =
|
||||
LocalContact(addressBook, id, fileName, eTag)
|
||||
|
||||
override fun newInstance(addressBook: AndroidAddressBook<LocalContact, *>, contact: Contact, fileName: String?, eTag: String?) =
|
||||
LocalContact(addressBook, contact, fileName, eTag)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,58 +10,68 @@ package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentValues
|
||||
import android.os.Build
|
||||
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.io.FileNotFoundException
|
||||
import java.util.*
|
||||
|
||||
class LocalEvent: AndroidEvent, LocalResource<Event> {
|
||||
class LocalEvent: AndroidEvent, LocalResource {
|
||||
|
||||
companion object {
|
||||
init {
|
||||
ICalendar.prodId = ProdId("+//IDN bitfire.at//${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ical4j/" + Constants.ical4jVersion)
|
||||
iCalendar.prodId = ProdId("+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ical4j/2.x")
|
||||
}
|
||||
|
||||
const val COLUMN_ETAG = Events.SYNC_DATA1
|
||||
const val COLUMN_FLAGS = Events.SYNC_DATA2
|
||||
const val COLUMN_SEQUENCE = Events.SYNC_DATA3
|
||||
val COLUMN_ETAG = CalendarContract.Events.SYNC_DATA1
|
||||
val COLUMN_UID = if (Build.VERSION.SDK_INT >= 17) Events.UID_2445 else Events.SYNC_DATA2
|
||||
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) {
|
||||
constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?): 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
|
||||
private constructor(calendar: AndroidCalendar<*>, id: Long, baseInfo: ContentValues?): super(calendar, id, baseInfo) {
|
||||
baseInfo?.let {
|
||||
fileName = it.getAsString(Events._SYNC_ID)
|
||||
eTag = it.getAsString(COLUMN_ETAG)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* process LocalEvent-specific fields */
|
||||
|
||||
@Throws(FileNotFoundException::class, CalendarStorageException::class)
|
||||
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)
|
||||
fileName = row.getAsString(Events._SYNC_ID)
|
||||
eTag = row.getAsString(COLUMN_ETAG)
|
||||
event.uid = row.getAsString(COLUMN_UID)
|
||||
|
||||
val isOrganizer = row.getAsInteger(Events.IS_ORGANIZER)
|
||||
weAreOrganizer = isOrganizer != null && isOrganizer != 0
|
||||
event.sequence = row.getAsInteger(COLUMN_SEQUENCE)
|
||||
if (Build.VERSION.SDK_INT >= 17) {
|
||||
val isOrganizer = row.getAsInteger(Events.IS_ORGANIZER)
|
||||
weAreOrganizer = isOrganizer != null && isOrganizer != 0
|
||||
} else {
|
||||
val organizer = row.getAsString(Events.ORGANIZER)
|
||||
weAreOrganizer = organizer == null || organizer == calendar.account.name
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(FileNotFoundException::class, CalendarStorageException::class)
|
||||
override fun buildEvent(recurrence: Event?, builder: ContentProviderOperation.Builder) {
|
||||
super.buildEvent(recurrence, builder)
|
||||
val event = requireNotNull(event)
|
||||
@@ -69,11 +79,10 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
|
||||
val buildException = recurrence != null
|
||||
val eventToBuild = recurrence ?: event
|
||||
|
||||
builder .withValue(Events.UID_2445, event.uid)
|
||||
builder .withValue(COLUMN_UID, event.uid)
|
||||
.withValue(COLUMN_SEQUENCE, eventToBuild.sequence)
|
||||
.withValue(Events.DIRTY, 0)
|
||||
.withValue(Events.DELETED, 0)
|
||||
.withValue(COLUMN_FLAGS, flags)
|
||||
.withValue(CalendarContract.Events.DIRTY, 0)
|
||||
.withValue(CalendarContract.Events.DELETED, 0)
|
||||
|
||||
if (buildException)
|
||||
builder .withValue(Events.ORIGINAL_SYNC_ID, fileName)
|
||||
@@ -83,48 +92,57 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
/* custom queries */
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun prepareForUpload() {
|
||||
try {
|
||||
var uid: String? = null
|
||||
calendar.provider.query(eventSyncURI(), arrayOf(COLUMN_UID), 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(COLUMN_UID, uid)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
fileName = newFileName
|
||||
event!!.uid = uid
|
||||
|
||||
} catch(e: Exception) {
|
||||
throw CalendarStorageException("Couldn't update UID", e)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun clearDirty(eTag: String?) {
|
||||
val values = ContentValues(2)
|
||||
values.put(Events.DIRTY, 0)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(COLUMN_SEQUENCE, event!!.sequence)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
try {
|
||||
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
|
||||
this.eTag = eTag
|
||||
} catch (e: Exception) {
|
||||
throw CalendarStorageException("Couldn't update UID", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
object Factory: AndroidEventFactory<LocalEvent> {
|
||||
override fun fromProvider(calendar: AndroidCalendar<*>, values: ContentValues) =
|
||||
LocalEvent(calendar, values)
|
||||
}
|
||||
|
||||
override fun newInstance(calendar: AndroidCalendar<*>, id: Long, baseInfo: ContentValues?) =
|
||||
LocalEvent(calendar, id, baseInfo)
|
||||
|
||||
override fun newInstance(calendar: AndroidCalendar<*>, event: Event) =
|
||||
LocalEvent(calendar, event, null, null)
|
||||
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ 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
|
||||
@@ -20,128 +19,105 @@ 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.dav4android.Constants
|
||||
import at.bitfire.vcard4android.*
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
class LocalGroup: AndroidGroup, LocalAddress {
|
||||
class LocalGroup: AndroidGroup, LocalResource {
|
||||
|
||||
companion object {
|
||||
|
||||
const val COLUMN_FLAGS = Groups.SYNC4
|
||||
|
||||
/** marshaled list of member UIDs, as sent by server */
|
||||
const val COLUMN_PENDING_MEMBERS = Groups.SYNC3
|
||||
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
|
||||
* @throws ContactsStorageException on contact provider errors
|
||||
*/
|
||||
@JvmStatic
|
||||
@Throws(ContactsStorageException::class)
|
||||
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")
|
||||
try {
|
||||
addressBook.provider!!.query(
|
||||
addressBook.syncAdapterURI(Groups.CONTENT_URI),
|
||||
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>()
|
||||
// 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!!
|
||||
// delete all memberships and cached memberships for this group
|
||||
for (contact in addressBook.getByGroupMembership(id)) {
|
||||
contact.removeGroupMemberships(batch)
|
||||
changeContactIDs.add(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")
|
||||
try {
|
||||
val member = addressBook.findContactByUID(uid)
|
||||
member.addToGroup(batch, id)
|
||||
changeContactIDs.add(member.id!!)
|
||||
} catch(e: FileNotFoundException) {
|
||||
Constants.log.log(Level.WARNING, "Group member not found: $uid", e)
|
||||
}
|
||||
}
|
||||
|
||||
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 { LocalContact(addressBook, it, null, null) }
|
||||
.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()
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't get pending memberships", e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
override var flags: Int = 0
|
||||
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, id: Long, fileName: String?, eTag: String?):
|
||||
super(addressBook, id, fileName, eTag)
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, contact: Contact, fileName: String?, eTag: String?):
|
||||
super(addressBook, contact, fileName, eTag)
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun clearDirty(eTag: String?) {
|
||||
val id = requireNotNull(id)
|
||||
|
||||
@@ -176,9 +152,38 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
||||
batch.commit()
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun prepareForUpload() {
|
||||
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
|
||||
}
|
||||
|
||||
@Throws(FileNotFoundException::class, ContactsStorageException::class)
|
||||
override fun contentValues(): ContentValues {
|
||||
val values = super.contentValues()
|
||||
|
||||
val members = Parcel.obtain()
|
||||
try {
|
||||
members.writeStringList(contact!!.members)
|
||||
values.put(COLUMN_PENDING_MEMBERS, members.marshall())
|
||||
} finally {
|
||||
members.recycle()
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Marks all members of the current group as dirty.
|
||||
*/
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun markMembersDirty() {
|
||||
val batch = BatchOperation(addressBook.provider!!)
|
||||
|
||||
@@ -192,45 +197,31 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
||||
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(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
|
||||
* @throws ContactsStorageException on contact provider errorst
|
||||
*/
|
||||
@Throws(ContactsStorageException::class)
|
||||
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)
|
||||
try {
|
||||
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.add(cursor.getLong(0))
|
||||
}
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't list group members", e)
|
||||
}
|
||||
return members
|
||||
}
|
||||
@@ -239,8 +230,13 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
||||
// factory
|
||||
|
||||
object Factory: AndroidGroupFactory<LocalGroup> {
|
||||
override fun fromProvider(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, values: ContentValues) =
|
||||
LocalGroup(addressBook, values)
|
||||
|
||||
override fun newInstance(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, id: Long, fileName: String?, eTag: String?) =
|
||||
LocalGroup(addressBook, id, fileName, eTag)
|
||||
|
||||
override fun newInstance(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, contact: Contact, fileName: String?, eTag: String?) =
|
||||
LocalGroup(addressBook, contact, fileName, eTag)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,50 +8,23 @@
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.net.Uri
|
||||
import at.bitfire.ical4android.CalendarStorageException
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
|
||||
interface LocalResource<in TData: Any> {
|
||||
interface LocalResource {
|
||||
|
||||
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 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
|
||||
*/
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun delete(): Int
|
||||
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun prepareForUpload()
|
||||
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun clearDirty(eTag: String?)
|
||||
|
||||
}
|
||||
@@ -10,92 +10,108 @@ 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 android.provider.CalendarContract.Events
|
||||
import at.bitfire.ical4android.*
|
||||
import org.dmfs.provider.tasks.TaskContract.Tasks
|
||||
import java.io.FileNotFoundException
|
||||
import java.text.ParseException
|
||||
import java.util.*
|
||||
|
||||
class LocalTask: AndroidTask, LocalResource<Task> {
|
||||
class LocalTask: AndroidTask, LocalResource {
|
||||
|
||||
companion object {
|
||||
const val COLUMN_ETAG = Tasks.SYNC1
|
||||
const val COLUMN_FLAGS = Tasks.SYNC2
|
||||
val COLUMN_ETAG = Tasks.SYNC1
|
||||
val COLUMN_UID = Tasks.SYNC2
|
||||
val COLUMN_SEQUENCE = Tasks.SYNC3
|
||||
}
|
||||
|
||||
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) {
|
||||
constructor(taskList: AndroidTaskList<*>, task: Task, fileName: String?, eTag: String?): 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
|
||||
private constructor(taskList: AndroidTaskList<*>, id: Long, baseInfo: ContentValues?): super(taskList, id) {
|
||||
baseInfo?.let {
|
||||
fileName = it.getAsString(Events._SYNC_ID)
|
||||
eTag = it.getAsString(COLUMN_ETAG)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* process LocalTask-specific fields */
|
||||
|
||||
@Throws(ParseException::class)
|
||||
override fun populateTask(values: ContentValues) {
|
||||
super.populateTask(values)
|
||||
|
||||
fileName = values.getAsString(Events._SYNC_ID)
|
||||
eTag = values.getAsString(COLUMN_ETAG)
|
||||
|
||||
val task = requireNotNull(task)
|
||||
task.uid = values.getAsString(COLUMN_UID)
|
||||
task.sequence = values.getAsInteger(COLUMN_SEQUENCE)
|
||||
}
|
||||
|
||||
@Throws(FileNotFoundException::class, CalendarStorageException::class)
|
||||
override fun buildTask(builder: ContentProviderOperation.Builder, update: Boolean) {
|
||||
super.buildTask(builder, update)
|
||||
val task = requireNotNull(task)
|
||||
|
||||
builder .withValue(Tasks._SYNC_ID, fileName)
|
||||
.withValue(COLUMN_UID, task.uid)
|
||||
.withValue(COLUMN_SEQUENCE, task.sequence)
|
||||
.withValue(COLUMN_ETAG, eTag)
|
||||
.withValue(COLUMN_FLAGS, flags)
|
||||
}
|
||||
|
||||
|
||||
/* custom queries */
|
||||
|
||||
override fun assignNameAndUID() {
|
||||
val uid = UUID.randomUUID().toString()
|
||||
val newFileName = "$uid.ics"
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun prepareForUpload() {
|
||||
try {
|
||||
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)
|
||||
val values = ContentValues(2)
|
||||
values.put(Tasks._SYNC_ID, newFileName)
|
||||
values.put(COLUMN_UID, uid)
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
this.flags = flags
|
||||
fileName = newFileName
|
||||
|
||||
task!!.uid = uid
|
||||
} catch (e: Exception) {
|
||||
throw CalendarStorageException("Couldn't update UID", e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun clearDirty(eTag: String?) {
|
||||
try {
|
||||
val values = ContentValues(2)
|
||||
values.put(Tasks._DIRTY, 0)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
|
||||
values.put(COLUMN_SEQUENCE, task!!.sequence)
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null)
|
||||
|
||||
this.eTag = eTag
|
||||
} catch (e: Exception) {
|
||||
throw CalendarStorageException("Couldn't update _DIRTY/ETag/SEQUENCE", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
object Factory: AndroidTaskFactory<LocalTask> {
|
||||
override fun fromProvider(taskList: AndroidTaskList<*>, values: ContentValues) =
|
||||
LocalTask(taskList, values)
|
||||
|
||||
override fun newInstance(calendar: AndroidTaskList<*>, id: Long, baseInfo: ContentValues?) =
|
||||
LocalTask(calendar, id, baseInfo)
|
||||
|
||||
override fun newInstance(calendar: AndroidTaskList<*>, task: Task) =
|
||||
LocalTask(calendar, task, null, null)
|
||||
|
||||
}
|
||||
}
|
||||
@@ -9,25 +9,21 @@
|
||||
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.Collection
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
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 org.dmfs.tasks.contract.TaskContract.TaskLists
|
||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||
import java.util.logging.Level
|
||||
import org.dmfs.provider.tasks.TaskContract.TaskLists
|
||||
import org.dmfs.provider.tasks.TaskContract.Tasks
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
class LocalTaskList private constructor(
|
||||
account: Account,
|
||||
@@ -37,21 +33,30 @@ class LocalTaskList private constructor(
|
||||
|
||||
companion object {
|
||||
|
||||
val defaultColor = 0xFFC3EA6E.toInt() // "DAVdroid green"
|
||||
|
||||
val COLUMN_CTAG = TaskLists.SYNC_VERSION
|
||||
|
||||
val BASE_INFO_COLUMNS = arrayOf(
|
||||
Tasks._ID,
|
||||
Tasks._SYNC_ID,
|
||||
LocalTask.COLUMN_ETAG
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
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
|
||||
}
|
||||
else {
|
||||
val provider = TaskProvider.acquire(context.contentResolver, TaskProvider.ProviderName.OpenTasks)
|
||||
provider?.use { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fun create(account: Account, provider: TaskProvider, info: Collection): Uri {
|
||||
@JvmStatic
|
||||
@Throws(CalendarStorageException::class)
|
||||
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)
|
||||
@@ -59,7 +64,7 @@ class LocalTaskList private constructor(
|
||||
return create(account, provider, values)
|
||||
}
|
||||
|
||||
@SuppressLint("Recycle")
|
||||
@JvmStatic
|
||||
@Throws(Exception::class)
|
||||
fun onRenameAccount(resolver: ContentResolver, oldName: String, newName: String) {
|
||||
var client: ContentProviderClient? = null
|
||||
@@ -71,55 +76,47 @@ class LocalTaskList private constructor(
|
||||
it.update(Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority), values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldName))
|
||||
}
|
||||
} finally {
|
||||
client?.closeCompat()
|
||||
if (Build.VERSION.SDK_INT >= 24)
|
||||
client?.close()
|
||||
else
|
||||
client?.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
|
||||
private fun valuesFromCollectionInfo(info: CollectionInfo, withColor: Boolean): ContentValues {
|
||||
val values = ContentValues(3)
|
||||
values.put(TaskLists._SYNC_ID, info.url.toString())
|
||||
values.put(TaskLists._SYNC_ID, info.url)
|
||||
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)
|
||||
values.put(TaskLists.LIST_COLOR, info.color ?: defaultColor)
|
||||
|
||||
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)
|
||||
}
|
||||
override fun taskBaseInfoColumns() = BASE_INFO_COLUMNS
|
||||
|
||||
|
||||
fun update(info: Collection, updateColor: Boolean) =
|
||||
update(valuesFromCollectionInfo(info, updateColor))
|
||||
@Throws(CalendarStorageException::class)
|
||||
fun update(info: CollectionInfo, updateColor: Boolean) {
|
||||
update(valuesFromCollectionInfo(info, updateColor));
|
||||
}
|
||||
|
||||
|
||||
override fun findDeleted() = queryTasks("${Tasks._DELETED}!=0", null)
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun getAll() = queryTasks(null, null)
|
||||
|
||||
override fun findDirty(): List<LocalTask> {
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun getDeleted() = queryTasks("${Tasks._DELETED}!=0", null)
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun getWithoutFileName() = queryTasks("${Tasks._SYNC_ID} IS NULL", null)
|
||||
|
||||
@Throws(FileNotFoundException::class, CalendarStorageException::class)
|
||||
override fun getDirty(): List<LocalTask> {
|
||||
val tasks = queryTasks("${Tasks._DIRTY}!=0", null)
|
||||
for (localTask in tasks) {
|
||||
val task = requireNotNull(localTask.task)
|
||||
@@ -132,23 +129,30 @@ class LocalTaskList private constructor(
|
||||
return tasks
|
||||
}
|
||||
|
||||
override fun findByName(name: String) =
|
||||
queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name)).firstOrNull()
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun getCTag(): String? =
|
||||
try {
|
||||
provider.client.query(taskListSyncUri(), arrayOf(COLUMN_CTAG), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return cursor.getString(0)
|
||||
}
|
||||
null
|
||||
} catch(e: Exception) {
|
||||
throw CalendarStorageException("Couldn't read local (last known) CTag", e)
|
||||
}
|
||||
|
||||
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()))
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun setCTag(cTag: String?) {
|
||||
try {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_CTAG, cTag)
|
||||
provider.client.update(taskListSyncUri(), values, null, null)
|
||||
} catch (e: Exception) {
|
||||
throw CalendarStorageException("Couldn't write local (last known) CTag", e)
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
|
||||
@@ -157,4 +161,4 @@ class LocalTaskList private constructor(
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
/*
|
||||
* 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.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
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.AppDatabase
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.model.Credentials
|
||||
import at.bitfire.davdroid.model.Service
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
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.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","FunctionName")
|
||||
/**
|
||||
* It seems that somehow some non-CalDAV accounts got OpenTasks syncable, which caused battery problems.
|
||||
* Disable it on those accounts for the future.
|
||||
*/
|
||||
private fun update_8_9() {
|
||||
val db = AppDatabase.getInstance(context)
|
||||
val hasCalDAV = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) != null
|
||||
if (!hasCalDAV && ContentResolver.getIsSyncable(account, OpenTasks.authority) != 0) {
|
||||
Logger.log.info("Disabling OpenTasks sync for $account")
|
||||
ContentResolver.setIsSyncable(account, OpenTasks.authority, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused","FunctionName")
|
||||
@SuppressLint("Recycle")
|
||||
/**
|
||||
* There is a mistake in this method. [TaskContract.Tasks.SYNC_VERSION] is used to store the
|
||||
* SEQUENCE and should not be used for the eTag.
|
||||
*/
|
||||
private fun update_7_8() {
|
||||
TaskProvider.acquire(context, OpenTasks)?.use { 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 = Collection(url = url, type = Collection.TYPE_ADDRESSBOOK, displayName = account.name)
|
||||
Logger.log.log(Level.INFO, "Creating new address book account", url)
|
||||
val addressBookAccount = Account(LocalAddressBook.accountName(account, info), context.getString(R.string.account_type_address_book))
|
||||
if (!accountManager.addAccountExplicitly(addressBookAccount, null, LocalAddressBook.initialUserData(account, info.url.toString())))
|
||||
throw ContactsStorageException("Couldn't create address book account")
|
||||
|
||||
// move contacts to new address book
|
||||
Logger.log.info("Moving contacts from $account to $addressBookAccount")
|
||||
val newAccount = ContentValues(2)
|
||||
newAccount.put(ContactsContract.RawContacts.ACCOUNT_NAME, addressBookAccount.name)
|
||||
newAccount.put(ContactsContract.RawContacts.ACCOUNT_TYPE, addressBookAccount.type)
|
||||
val affected = provider.update(ContactsContract.RawContacts.CONTENT_URI.buildUpon()
|
||||
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
|
||||
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
|
||||
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build(),
|
||||
newAccount,
|
||||
"${ContactsContract.RawContacts.ACCOUNT_NAME}=? AND ${ContactsContract.RawContacts.ACCOUNT_TYPE}=?",
|
||||
arrayOf(account.name, account.type))
|
||||
Logger.log.info("$affected contacts moved to new address book")
|
||||
}
|
||||
|
||||
ContactsContract.SyncState.set(provider, account, null)
|
||||
}
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't migrate contacts to new address book", e)
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
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)
|
||||
}
|
||||
|
||||
// updates from AccountSettings version 2 and below are not supported anymore
|
||||
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* 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())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* 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>
|
||||
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
/*
|
||||
* 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 androidx.annotation.WorkerThread
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
@WorkerThread
|
||||
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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
/*
|
||||
* 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.AppDatabase
|
||||
|
||||
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(first = true, second = 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
|
||||
AppDatabase.getInstance(context)
|
||||
}
|
||||
|
||||
|
||||
class Factory : ISettingsProviderFactory {
|
||||
override fun getProviders(context: Context) = listOf(SharedPreferencesProvider(context))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,87 +7,26 @@
|
||||
*/
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.*
|
||||
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 androidx.annotation.WorkerThread
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.ui.setup.LoginActivity
|
||||
import java.util.logging.Level
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class AccountAuthenticatorService: Service() {
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
|
||||
@WorkerThread
|
||||
fun cleanupAccounts(context: Context) {
|
||||
Logger.log.info("Cleaning up orphaned accounts")
|
||||
|
||||
val accountManager = AccountManager.get(context)
|
||||
val accountNames = accountManager.getAccountsByType(context.getString(R.string.account_type))
|
||||
.map { it.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
|
||||
val db = AppDatabase.getInstance(context)
|
||||
val serviceDao = db.serviceDao()
|
||||
if (accountNames.isEmpty())
|
||||
serviceDao.deleteAll()
|
||||
else
|
||||
serviceDao.deleteExceptAccounts(accountNames.toTypedArray())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
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 == AccountManager.ACTION_AUTHENTICATOR_INTENT }
|
||||
|
||||
|
||||
override fun onAccountsUpdated(accounts: Array<out Account>?) {
|
||||
thread {
|
||||
cleanupAccounts(this)
|
||||
}
|
||||
}
|
||||
accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT }
|
||||
|
||||
|
||||
private class AccountAuthenticator(
|
||||
|
||||
@@ -12,14 +12,13 @@ import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
|
||||
@Suppress("ImplicitNullableNothingType")
|
||||
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
|
||||
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
|
||||
|
||||
}
|
||||
+68
-62
@@ -7,94 +7,104 @@
|
||||
*/
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
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.AccountSettings
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.model.Service
|
||||
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.account.AccountActivity
|
||||
import okhttp3.HttpUrl
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import java.util.logging.Level
|
||||
|
||||
class AddressBooksSyncAdapterService : SyncAdapterService() {
|
||||
class AddressBooksSyncAdapterService: SyncAdapterService() {
|
||||
|
||||
override fun syncAdapter() = AddressBooksSyncAdapter(this)
|
||||
|
||||
|
||||
class AddressBooksSyncAdapter(
|
||||
protected class AddressBooksSyncAdapter(
|
||||
context: Context
|
||||
) : SyncAdapter(context) {
|
||||
): SyncAdapter(context) {
|
||||
|
||||
override fun sync(account: Account, extras: Bundle, authority: String, addressBooksProvider: ContentProviderClient, syncResult: SyncResult) {
|
||||
val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
|
||||
if (contactsProvider == null) {
|
||||
Logger.log.severe("Couldn't access contacts provider")
|
||||
syncResult.databaseError = true
|
||||
return
|
||||
}
|
||||
|
||||
override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
try {
|
||||
val accountSettings = AccountSettings(context, account)
|
||||
val settings = 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))
|
||||
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
|
||||
return
|
||||
|
||||
if (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) {
|
||||
updateLocalAddressBooks(contactsProvider, account)
|
||||
|
||||
val accountManager = AccountManager.get(context)
|
||||
for (addressBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) {
|
||||
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): Boolean {
|
||||
val db = AppDatabase.getInstance(context)
|
||||
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV)
|
||||
private fun updateLocalAddressBooks(provider: ContentProviderClient, account: Account) {
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.readableDatabase
|
||||
|
||||
val remoteAddressBooks = mutableMapOf<HttpUrl, Collection>()
|
||||
if (service != null)
|
||||
for (collection in db.collectionDao().getByServiceAndSync(service.id))
|
||||
remoteAddressBooks[collection.url] = collection
|
||||
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
|
||||
}
|
||||
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
|
||||
if (remoteAddressBooks.isEmpty())
|
||||
Logger.log.info("No contacts permission, but no address book selected for synchronization")
|
||||
else {
|
||||
// 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)
|
||||
fun remoteAddressBooks(service: Long?): MutableMap<String, CollectionInfo> {
|
||||
val collections = mutableMapOf<String, 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
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
|
||||
try {
|
||||
if (contactsProvider == null) {
|
||||
Logger.log.severe("Couldn't access contacts provider")
|
||||
syncResult.databaseError = true
|
||||
return false
|
||||
}
|
||||
// enumerate remote and local address books
|
||||
val service = getService()
|
||||
val remote = remoteAddressBooks(service)
|
||||
|
||||
// delete/update local address books
|
||||
for (addressBook in LocalAddressBook.findAll(context, contactsProvider, account)) {
|
||||
val url = HttpUrl.parse(addressBook.url)!!
|
||||
val info = remoteAddressBooks[url]
|
||||
for (addressBook in LocalAddressBook.find(context, provider, account)) {
|
||||
val url = addressBook.getURL()
|
||||
val info = remote[url]
|
||||
if (info == null) {
|
||||
Logger.log.log(Level.INFO, "Deleting obsolete local address book", url)
|
||||
addressBook.delete()
|
||||
@@ -103,24 +113,20 @@ class AddressBooksSyncAdapterService : SyncAdapterService() {
|
||||
try {
|
||||
Logger.log.log(Level.FINE, "Updating local address book $url", info)
|
||||
addressBook.update(info)
|
||||
} catch (e: Exception) {
|
||||
} catch(e: ContactsStorageException) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't rename address book account", e)
|
||||
}
|
||||
// we already have a local address book for this remote collection, don't take into consideration anymore
|
||||
remoteAddressBooks -= url
|
||||
remote -= url
|
||||
}
|
||||
}
|
||||
|
||||
// create new local address books
|
||||
for ((_, info) in remoteAddressBooks) {
|
||||
for ((_, info) in remote) {
|
||||
Logger.log.log(Level.INFO, "Adding local address book", info)
|
||||
LocalAddressBook.create(context, contactsProvider, account, info)
|
||||
LocalAddressBook.create(context, provider, account, info)
|
||||
}
|
||||
} finally {
|
||||
contactsProvider?.closeCompat()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,27 +9,28 @@
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
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.dav4android.DavCalendar
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.exception.DavException
|
||||
import at.bitfire.dav4android.property.CalendarData
|
||||
import at.bitfire.dav4android.property.GetCTag
|
||||
import at.bitfire.dav4android.property.GetETag
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.Constants
|
||||
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 org.apache.commons.collections4.ListUtils
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.Reader
|
||||
import java.io.StringReader
|
||||
@@ -37,130 +38,148 @@ import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Synchronization manager for CalDAV collections; handles events (VEVENT)
|
||||
* Synchronization manager for CalDAV collections; handles events ({@code VEVENT}).
|
||||
*/
|
||||
class CalendarSyncManager(
|
||||
context: Context,
|
||||
account: Account,
|
||||
accountSettings: AccountSettings,
|
||||
settings: AccountSettings,
|
||||
extras: Bundle,
|
||||
authority: String,
|
||||
syncResult: SyncResult,
|
||||
localCalendar: LocalCalendar
|
||||
): SyncManager<LocalEvent, LocalCalendar, DavCalendar>(context, account, accountSettings, extras, authority, syncResult, localCalendar) {
|
||||
val provider: ContentProviderClient,
|
||||
val localCalendar: LocalCalendar
|
||||
): SyncManager(context, account, settings, extras, authority, syncResult, "calendar/${localCalendar.id}") {
|
||||
|
||||
val MAX_MULTIGET = 20
|
||||
|
||||
|
||||
init {
|
||||
localCollection = localCalendar
|
||||
}
|
||||
|
||||
override fun notificationId() = Constants.NOTIFICATION_CALENDAR_SYNC
|
||||
|
||||
override fun getSyncErrorTitle() = context.getString(R.string.sync_error_calendar, account.name)!!
|
||||
|
||||
|
||||
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()
|
||||
|
||||
collectionURL = HttpUrl.parse(localCalendar.name ?: return false) ?: return false
|
||||
davCollection = DavCalendar(httpClient, collectionURL)
|
||||
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 queryCapabilities() {
|
||||
davCollection.propfind(0, GetCTag.NAME)
|
||||
}
|
||||
|
||||
override fun listAllRemote(callback: DavResponseCallback) {
|
||||
override fun prepareDirty() {
|
||||
super.prepareDirty()
|
||||
localCalendar.processDirtyExceptions()
|
||||
}
|
||||
|
||||
override fun prepareUpload(resource: LocalResource): RequestBody {
|
||||
if (resource is LocalEvent) {
|
||||
val event = requireNotNull(resource.event)
|
||||
Logger.log.log(Level.FINE, "Preparing upload of event ${resource.fileName}", event)
|
||||
|
||||
val os = ByteArrayOutputStream()
|
||||
event.write(os)
|
||||
|
||||
return RequestBody.create(
|
||||
DavCalendar.MIME_ICALENDAR,
|
||||
os.toByteArray()
|
||||
)
|
||||
} else
|
||||
throw IllegalArgumentException("resource must be a LocalEvent")
|
||||
}
|
||||
|
||||
override fun listRemote() {
|
||||
// calculate time range limits
|
||||
var limitStart: Date? = null
|
||||
accountSettings.getTimeRangePastDays()?.let { pastDays ->
|
||||
settings.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)
|
||||
// fetch list of remote VEVENTs and build hash table to index file name
|
||||
val calendar = davCalendar()
|
||||
currentDavResource = calendar
|
||||
calendar.calendarQuery("VEVENT", limitStart, null)
|
||||
|
||||
remoteResources = HashMap<String, DavResource>(davCollection.members.size)
|
||||
for (iCal in davCollection.members) {
|
||||
val fileName = iCal.fileName()
|
||||
Logger.log.fine("Found remote VEVENT: $fileName")
|
||||
remoteResources[fileName] = iCal
|
||||
}
|
||||
|
||||
currentDavResource = null
|
||||
}
|
||||
|
||||
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")
|
||||
override fun downloadRemote() {
|
||||
Logger.log.info("Downloading ${toDownload.size} events ($MAX_MULTIGET at once)")
|
||||
|
||||
response.body()!!.use {
|
||||
processVEvent(resource.fileName(), eTag, it.charStream())
|
||||
}
|
||||
// download new/updated iCalendars from server
|
||||
for (bunch in ListUtils.partition(toDownload.toList(), MAX_MULTIGET)) {
|
||||
if (Thread.interrupted())
|
||||
return
|
||||
|
||||
Logger.log.info("Downloading ${bunch.joinToString(", ")}")
|
||||
|
||||
if (bunch.size == 1) {
|
||||
// only one contact, use GET
|
||||
val remote = bunch.first()
|
||||
currentDavResource = remote
|
||||
|
||||
val body = remote.get("text/calendar")
|
||||
|
||||
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
|
||||
val eTag = remote.properties[GetETag.NAME] as GetETag?
|
||||
if (eTag == null || eTag.eTag.isNullOrEmpty())
|
||||
throw DavException("Received CalDAV GET response without ETag for ${remote.location}")
|
||||
|
||||
body.charStream()?.use { reader ->
|
||||
processVEvent(remote.fileName(), eTag.eTag!!, reader)
|
||||
}
|
||||
|
||||
} else {
|
||||
// multiple contacts, use multi-get
|
||||
val calendar = davCalendar()
|
||||
currentDavResource = calendar
|
||||
calendar.multiget(bunch.map { it.location })
|
||||
|
||||
// process multiget results
|
||||
for (remote in davCollection.members) {
|
||||
currentDavResource = remote
|
||||
|
||||
val eTag = (remote.properties[GetETag.NAME] as GetETag?)?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
|
||||
val calendarData = remote.properties[CalendarData.NAME] as CalendarData?
|
||||
val iCalendar = calendarData?.iCalendar
|
||||
?: throw DavException("Received multi-get response without event data")
|
||||
|
||||
processVEvent(remote.fileName(), eTag, StringReader(iCalendar))
|
||||
}
|
||||
}
|
||||
} 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() {
|
||||
currentDavResource = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun davCalendar() = davCollection as DavCalendar
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -168,25 +187,24 @@ class CalendarSyncManager(
|
||||
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++
|
||||
}
|
||||
val localEvent = localResources[fileName] as LocalEvent?
|
||||
currentLocalResource = localEvent
|
||||
if (localEvent != null) {
|
||||
Logger.log.info("Updating $fileName in local calendar")
|
||||
localEvent.eTag = eTag
|
||||
localEvent.update(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
} else {
|
||||
Logger.log.info("Adding $fileName to local calendar")
|
||||
val newEvent = LocalEvent(localCalendar, newData, fileName, eTag)
|
||||
currentLocalResource = newEvent
|
||||
newEvent.add()
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
} else
|
||||
Logger.log.info("Received VCALENDAR with not exactly one VEVENT with UID and without RECURRENCE-ID; ignoring $fileName")
|
||||
Logger.log.severe("Received VCALENDAR with not exactly one VEVENT with UID, but without RECURRENCE-ID; ignoring $fileName")
|
||||
|
||||
currentLocalResource = null
|
||||
}
|
||||
|
||||
override fun notifyInvalidResourceTitle(): String =
|
||||
context.getString(R.string.sync_invalid_event)
|
||||
|
||||
}
|
||||
@@ -8,93 +8,111 @@
|
||||
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.content.*
|
||||
import android.database.DatabaseUtils
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.model.Service
|
||||
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)
|
||||
override fun syncAdapter() = SyncAdapter(this)
|
||||
|
||||
|
||||
class CalendarsSyncAdapter(
|
||||
protected class SyncAdapter(
|
||||
context: Context
|
||||
): SyncAdapter(context) {
|
||||
): SyncAdapterService.SyncAdapter(context) {
|
||||
|
||||
override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
try {
|
||||
val accountSettings = AccountSettings(context, account)
|
||||
val settings = 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))
|
||||
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
|
||||
return
|
||||
|
||||
if (accountSettings.getEventColors())
|
||||
AndroidCalendar.insertColors(provider, account)
|
||||
else
|
||||
AndroidCalendar.removeColors(provider, account)
|
||||
|
||||
updateLocalCalendars(provider, account, accountSettings)
|
||||
updateLocalCalendars(provider, account, settings)
|
||||
|
||||
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()
|
||||
}
|
||||
CalendarSyncManager(context, account, settings, extras, authority, syncResult, provider, calendar)
|
||||
.performSync()
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't sync calendars", e)
|
||||
}
|
||||
|
||||
Logger.log.info("Calendar sync complete")
|
||||
}
|
||||
|
||||
private fun updateLocalCalendars(provider: ContentProviderClient, account: Account, settings: AccountSettings) {
|
||||
val db = AppDatabase.getInstance(context)
|
||||
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV)
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.readableDatabase
|
||||
|
||||
val remoteCalendars = mutableMapOf<HttpUrl, Collection>()
|
||||
if (service != null)
|
||||
for (collection in db.collectionDao().getSyncCalendars(service.id)) {
|
||||
remoteCalendars[collection.url] = collection
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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 = remoteCalendars[url]
|
||||
if (info == null) {
|
||||
Logger.log.log(Level.INFO, "Deleting obsolete local calendar", url)
|
||||
calendar.delete()
|
||||
} else {
|
||||
// remote CollectionInfo found for this local collection, update data
|
||||
Logger.log.log(Level.FINE, "Updating local calendar $url", info)
|
||||
calendar.update(info, updateColors)
|
||||
// we already have a local calendar for this remote collection, don't take into consideration anymore
|
||||
remoteCalendars -= url
|
||||
fun remoteCalendars(service: Long?): MutableMap<String, CollectionInfo> {
|
||||
val collections = mutableMapOf<String, 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
|
||||
}
|
||||
|
||||
// create new local calendars
|
||||
for ((_, info) in remoteCalendars) {
|
||||
Logger.log.log(Level.INFO, "Adding local calendar", info)
|
||||
LocalCalendar.create(account, provider, info)
|
||||
// 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 { url ->
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,58 +14,37 @@ import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import android.provider.ContactsContract
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
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(
|
||||
protected 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))
|
||||
val settings = AccountSettings(context, addressBook.getMainAccount())
|
||||
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
|
||||
return
|
||||
|
||||
Logger.log.info("Synchronizing address book: ${addressBook.url}")
|
||||
Logger.log.info("Taking settings from: ${addressBook.mainAccount}")
|
||||
Logger.log.info("Synchronizing address book: ${addressBook.getURL()}")
|
||||
Logger.log.info("Taking settings from: ${addressBook.getMainAccount()}")
|
||||
|
||||
ContactsSyncManager(context, account, accountSettings, extras, authority, syncResult, provider, addressBook).use {
|
||||
it.performSync()
|
||||
}
|
||||
ContactsSyncManager(context, account, settings, extras, authority, syncResult, provider, addressBook)
|
||||
.performSync()
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't sync contacts", e)
|
||||
}
|
||||
|
||||
Logger.log.info("Contacts sync complete")
|
||||
}
|
||||
|
||||
|
||||
@@ -12,222 +12,178 @@ import android.accounts.Account
|
||||
import android.content.*
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.RemoteException
|
||||
import android.provider.ContactsContract
|
||||
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.dav4android.DavAddressBook
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.exception.DavException
|
||||
import at.bitfire.dav4android.property.*
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.Constants
|
||||
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.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalContact
|
||||
import at.bitfire.davdroid.resource.LocalGroup
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
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.io.CannotParseException
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import org.apache.commons.collections4.ListUtils
|
||||
import java.io.*
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Synchronization manager for CardDAV collections; handles contacts and groups.
|
||||
* <p>Synchronization manager for CardDAV collections; handles contacts and groups.</p>
|
||||
*
|
||||
* 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.
|
||||
* <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>
|
||||
*/
|
||||
class ContactsSyncManager(
|
||||
context: Context,
|
||||
account: Account,
|
||||
accountSettings: AccountSettings,
|
||||
settings: AccountSettings,
|
||||
extras: Bundle,
|
||||
authority: String,
|
||||
syncResult: SyncResult,
|
||||
val provider: ContentProviderClient,
|
||||
localAddressBook: LocalAddressBook
|
||||
): SyncManager<LocalAddress, LocalAddressBook, DavAddressBook>(context, account, accountSettings, extras, authority, syncResult, localAddressBook) {
|
||||
val localAddressBook: LocalAddressBook
|
||||
): SyncManager(context, account, settings, extras, authority, syncResult, "addressBook") {
|
||||
|
||||
companion object {
|
||||
infix fun <T> Set<T>.disjunct(other: Set<T>) = (this - other) union (other - this)
|
||||
}
|
||||
|
||||
private val readOnly = localAddressBook.readOnly
|
||||
val MAX_MULTIGET = 10
|
||||
|
||||
private var hasVCard4 = false
|
||||
private val groupMethod = accountSettings.getGroupMethod()
|
||||
private lateinit var groupMethod: GroupMethod
|
||||
|
||||
/**
|
||||
* Used to download images which are referenced by URL
|
||||
*/
|
||||
private lateinit var resourceDownloader: ResourceDownloader
|
||||
|
||||
init {
|
||||
localCollection = localAddressBook
|
||||
}
|
||||
|
||||
override fun notificationId() = Constants.NOTIFICATION_CONTACTS_SYNC
|
||||
|
||||
override fun getSyncErrorTitle() = context.getString(R.string.sync_error_contacts, account.name)!!
|
||||
|
||||
|
||||
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
|
||||
val reallyDirty = localAddressBook.verifyDirty()
|
||||
val deleted = localAddressBook.getDeleted().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)
|
||||
// set up Contacts Provider Settings
|
||||
val values = ContentValues(2)
|
||||
values.put(ContactsContract.Settings.SHOULD_SYNC, 1)
|
||||
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
|
||||
localAddressBook.updateSettings(values)
|
||||
|
||||
resourceDownloader = ResourceDownloader(davCollection.location)
|
||||
collectionURL = HttpUrl.parse(localAddressBook.getURL()) ?: return false
|
||||
davCollection = DavAddressBook(httpClient, collectionURL)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun queryCapabilities(): SyncState? {
|
||||
override fun queryCapabilities() {
|
||||
// prepare remote address book
|
||||
davCollection.propfind(0, SupportedAddressData.NAME, GetCTag.NAME)
|
||||
(davCollection.properties[SupportedAddressData.NAME] as SupportedAddressData?)?.let {
|
||||
hasVCard4 = it.hasVCard4()
|
||||
}
|
||||
Logger.log.info("Server advertises VCard/4 support: $hasVCard4")
|
||||
|
||||
groupMethod = settings.getGroupMethod()
|
||||
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
|
||||
}
|
||||
localAddressBook.includeGroups = groupMethod == GroupMethod.GROUP_VCARDS
|
||||
}
|
||||
|
||||
override fun syncAlgorithm() = if (hasCollectionSync)
|
||||
SyncAlgorithm.COLLECTION_SYNC
|
||||
else
|
||||
SyncAlgorithm.PROPFIND_REPORT
|
||||
override fun prepareDirty() {
|
||||
super.prepareDirty()
|
||||
|
||||
override fun processLocallyDeleted() =
|
||||
if (readOnly) {
|
||||
for (group in localCollection.findDeletedGroups()) {
|
||||
Logger.log.warning("Restoring locally deleted group (read-only address book!)")
|
||||
useLocal(group) { it.resetDeleted() }
|
||||
}
|
||||
if (groupMethod == GroupMethod.CATEGORIES) {
|
||||
/* groups memberships are represented as contact CATEGORIES */
|
||||
|
||||
for (contact in localCollection.findDeletedContacts()) {
|
||||
Logger.log.warning("Restoring locally deleted contact (read-only address book!)")
|
||||
useLocal(contact) { it.resetDeleted() }
|
||||
}
|
||||
|
||||
false
|
||||
} else
|
||||
// mirror deletions to remote collection (DELETE)
|
||||
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) }
|
||||
// groups with DELETED=1: set all members to dirty, then remove group
|
||||
for (group in localAddressBook.getDeletedGroups()) {
|
||||
Logger.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()
|
||||
}
|
||||
|
||||
for (contact in localCollection.findDirtyContacts()) {
|
||||
Logger.log.warning("Resetting locally modified contact to ETag=null (read-only address book!)")
|
||||
useLocal(contact) { it.clearDirty(null) }
|
||||
// groups with DIRTY=1: mark all memberships as dirty, then clean DIRTY flag of group
|
||||
for (group in localAddressBook.getDirtyGroups()) {
|
||||
Logger.log.fine("Marking members of modified group $group as dirty")
|
||||
group.markMembersDirty()
|
||||
group.clearDirty(null)
|
||||
}
|
||||
|
||||
} else {
|
||||
if (groupMethod == GroupMethod.CATEGORIES) {
|
||||
/* groups memberships are represented as contact CATEGORIES */
|
||||
/* groups as separate VCards: there are group contacts and individual contacts */
|
||||
|
||||
// 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)
|
||||
// mark groups with changed members as dirty
|
||||
val batch = BatchOperation(localAddressBook.provider!!)
|
||||
for (contact in localAddressBook.getDirtyContacts())
|
||||
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(localAddressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)))
|
||||
.withValue(Groups.DIRTY, 1)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
}
|
||||
} catch(e: FileNotFoundException) {
|
||||
}
|
||||
} 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()
|
||||
}
|
||||
batch.commit()
|
||||
}
|
||||
|
||||
// generate UID/file name for newly created contacts
|
||||
return super.uploadDirty()
|
||||
}
|
||||
|
||||
override fun prepareUpload(resource: LocalAddress): RequestBody = useLocal(resource) {
|
||||
override fun prepareUpload(resource: LocalResource): RequestBody {
|
||||
val contact: Contact
|
||||
if (resource is LocalContact) {
|
||||
contact = resource.contact!!
|
||||
@@ -235,76 +191,112 @@ class ContactsSyncManager(
|
||||
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)
|
||||
try {
|
||||
provider.query(
|
||||
localAddressBook.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)
|
||||
}
|
||||
}
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't find group for adding CATEGORIES", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (resource is LocalGroup)
|
||||
contact = resource.contact!!
|
||||
else
|
||||
throw IllegalArgumentException("resource must be LocalContact or LocalGroup")
|
||||
throw IllegalArgumentException("resource must be a LocalContact or a 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(
|
||||
return 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 listRemote() {
|
||||
val addressBook = davAddressBook()
|
||||
currentDavResource = addressBook
|
||||
|
||||
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")
|
||||
// fetch list of remote VCards and build hash table to index file name
|
||||
addressBook.propfind(1, ResourceType.NAME, GetETag.NAME)
|
||||
|
||||
response.body()!!.use {
|
||||
processVCard(resource.fileName(), eTag, it.charStream(), resourceDownloader)
|
||||
}
|
||||
remoteResources = HashMap<String, DavResource>(davCollection.members.size)
|
||||
for (vCard in davCollection.members) {
|
||||
// ignore member collections
|
||||
val type = vCard.properties[ResourceType.NAME] as ResourceType?
|
||||
if (type != null && type.types.contains(ResourceType.COLLECTION))
|
||||
continue
|
||||
|
||||
val fileName = vCard.fileName()
|
||||
Logger.log.fine("Found remote VCard: $fileName")
|
||||
remoteResources[fileName] = vCard
|
||||
}
|
||||
|
||||
currentDavResource = null
|
||||
}
|
||||
|
||||
override fun downloadRemote() {
|
||||
Logger.log.info("Downloading ${toDownload.size} contacts ($MAX_MULTIGET at once)")
|
||||
|
||||
// prepare downloader which may be used to download external resource like contact photos
|
||||
val downloader = ResourceDownloader(collectionURL)
|
||||
|
||||
// download new/updated VCards from server
|
||||
for (bunch in ListUtils.partition(toDownload.toList(), MAX_MULTIGET)) {
|
||||
if (Thread.interrupted())
|
||||
return
|
||||
|
||||
Logger.log.info("Downloading ${bunch.joinToString(", ")}")
|
||||
|
||||
if (bunch.size == 1) {
|
||||
// only one contact, use GET
|
||||
val remote = bunch.first()
|
||||
currentDavResource = remote
|
||||
|
||||
val 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]
|
||||
val eTag = remote.properties[GetETag.NAME] as GetETag?
|
||||
if (eTag == null || eTag.eTag.isNullOrEmpty())
|
||||
throw DavException("Received CardDAV GET response without ETag for ${remote.location}")
|
||||
|
||||
body.charStream().use { reader ->
|
||||
processVCard(remote.fileName(), eTag.eTag!!, reader, downloader)
|
||||
}
|
||||
|
||||
} else {
|
||||
// multiple contacts, use multi-get
|
||||
val addressBook = davAddressBook()
|
||||
currentDavResource = addressBook
|
||||
addressBook.multiget(bunch.map { it.location }, hasVCard4)
|
||||
|
||||
// process multi-get results
|
||||
for (remote in davCollection.members) {
|
||||
currentDavResource = remote
|
||||
|
||||
val eTag = (remote.properties[GetETag.NAME] as GetETag?)?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
|
||||
val addressData = remote.properties[AddressData.NAME] as AddressData?
|
||||
val vCard = addressData?.vCard
|
||||
?: throw DavException("Received multi-get response without address data")
|
||||
|
||||
processVCard(remote.fileName(), eTag, StringReader(vCard), downloader)
|
||||
}
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
currentDavResource = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun postProcess() {
|
||||
@@ -313,106 +305,104 @@ class ContactsSyncManager(
|
||||
|
||||
// remove empty groups
|
||||
Logger.log.info("Removing empty groups")
|
||||
localCollection.removeEmptyGroups()
|
||||
localAddressBook.removeEmptyGroups()
|
||||
|
||||
} else {
|
||||
/* VCard4 group handling: there are group contacts and individual contacts */
|
||||
Logger.log.info("Assigning memberships of downloaded contact groups")
|
||||
LocalGroup.applyPendingMemberships(localCollection)
|
||||
LocalGroup.applyPendingMemberships(localAddressBook)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun davAddressBook() = davCollection as DavAddressBook
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
val contacts = Contact.fromReader(reader, downloader)
|
||||
if (contacts.isEmpty()) {
|
||||
Logger.log.warning("Received vCard without data, ignoring")
|
||||
Logger.log.warning("Received VCard without data, ignoring")
|
||||
return
|
||||
} else if (contacts.size > 1)
|
||||
Logger.log.warning("Received multiple vCards, using first one")
|
||||
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
|
||||
groupMethod = GroupMethod.GROUP_VCARDS
|
||||
Logger.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
|
||||
useLocal(localCollection.findByName(fileName)) {
|
||||
var local = it
|
||||
if (local != null) {
|
||||
Logger.log.log(Level.INFO, "Updating $fileName in local address book", newData)
|
||||
var local = localResources[fileName]
|
||||
currentLocalResource = local
|
||||
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++
|
||||
if (local is LocalGroup && newData.group) {
|
||||
// update group
|
||||
local.eTag = eTag
|
||||
local.updateFromServer(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 if (local is LocalContact && !newData.group) {
|
||||
// update contact
|
||||
local.eTag = eTag
|
||||
local.update(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
|
||||
} else {
|
||||
// group has become an individual contact or vice versa
|
||||
local.delete()
|
||||
local = null
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
|
||||
if (local == null) {
|
||||
if (newData.group) {
|
||||
Logger.log.log(Level.INFO, "Creating local group", newData)
|
||||
val group = LocalGroup(localAddressBook, newData, fileName, eTag)
|
||||
currentLocalResource = group
|
||||
group.create()
|
||||
|
||||
local = group
|
||||
} else {
|
||||
Logger.log.log(Level.INFO, "Creating local contact", newData)
|
||||
val contact = LocalContact(localAddressBook, newData, fileName, eTag)
|
||||
currentLocalResource = contact
|
||||
contact.create()
|
||||
|
||||
local = contact
|
||||
}
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
|
||||
if (groupMethod == GroupMethod.CATEGORIES && local is LocalContact) {
|
||||
// VCard3: update group memberships from CATEGORIES
|
||||
currentLocalResource = local
|
||||
|
||||
val batch = BatchOperation(provider)
|
||||
Logger.log.log(Level.FINE, "Removing contact group memberships")
|
||||
local.removeGroupMemberships(batch)
|
||||
|
||||
for (category in local.contact!!.categories) {
|
||||
val groupID = localAddressBook.findOrCreateGroup(category)
|
||||
Logger.log.log(Level.FINE, "Adding membership in group $category ($groupID)")
|
||||
local.addToGroup(batch, groupID)
|
||||
}
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O && local is LocalContact)
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
local.updateHashCode(null)
|
||||
|
||||
currentLocalResource = null
|
||||
}
|
||||
|
||||
|
||||
@@ -429,15 +419,27 @@ class ContactsSyncManager(
|
||||
return null
|
||||
}
|
||||
|
||||
val host = httpUrl.host()
|
||||
if (host == null) {
|
||||
Logger.log.log(Level.SEVERE, "External resource URL doesn't specify a host name", url)
|
||||
return null
|
||||
}
|
||||
|
||||
var resourceClient = HttpClient.create(context)
|
||||
|
||||
// authenticate only against a certain host, and only upon request
|
||||
val builder = HttpClient.Builder(context, baseUrl.host(), accountSettings.credentials())
|
||||
val username = settings.username()
|
||||
val password = settings.password()
|
||||
if (username != null && password != null)
|
||||
resourceClient = HttpClient.addAuthentication(resourceClient, baseUrl.host(), username, password)
|
||||
|
||||
// allow redirects
|
||||
builder.followRedirects(true)
|
||||
resourceClient = resourceClient.newBuilder()
|
||||
.followRedirects(true)
|
||||
.build()
|
||||
|
||||
val client = builder.build()
|
||||
try {
|
||||
val response = client.okHttpClient.newCall(Request.Builder()
|
||||
val response = resourceClient.newCall(Request.Builder()
|
||||
.get()
|
||||
.url(httpUrl)
|
||||
.build()).execute()
|
||||
@@ -448,14 +450,9 @@ class ContactsSyncManager(
|
||||
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)
|
||||
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.AbstractAccountAuthenticator
|
||||
@@ -26,7 +27,7 @@ class NullAuthenticatorService: Service() {
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?) =
|
||||
accountAuthenticator.iBinder.takeIf { intent?.action == AccountManager.ACTION_AUTHENTICATOR_INTENT }
|
||||
accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT }
|
||||
|
||||
|
||||
private class AccountAuthenticator(
|
||||
|
||||
@@ -8,40 +8,27 @@
|
||||
|
||||
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 android.support.v4.app.NotificationCompat
|
||||
import android.support.v4.app.NotificationManagerCompat
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.ui.AccountSettingsActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.davdroid.ui.account.AccountActivity
|
||||
import java.lang.ref.WeakReference
|
||||
import at.bitfire.davdroid.ui.PermissionsActivity
|
||||
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
|
||||
abstract protected fun syncAdapter(): AbstractThreadedSyncAdapter
|
||||
|
||||
override fun onBind(intent: Intent?) = syncAdapter().syncAdapterBinder!!
|
||||
|
||||
@@ -55,40 +42,31 @@ abstract class SyncAdapterService: Service() {
|
||||
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)
|
||||
}
|
||||
// required for dav4android (ServiceLoader)
|
||||
Thread.currentThread().contextClassLoader = context.classLoader
|
||||
|
||||
try {
|
||||
// required for dav4jvm (ServiceLoader)
|
||||
Thread.currentThread().contextClassLoader = context.classLoader
|
||||
sync(account, extras, authority, provider, syncResult)
|
||||
|
||||
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")
|
||||
Logger.log.info("Sync for $authority complete")
|
||||
}
|
||||
|
||||
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)
|
||||
val intent = Intent(context, PermissionsActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
notifyPermissions(intent)
|
||||
val notify = NotificationCompat.Builder(context)
|
||||
.setSmallIcon(R.drawable.ic_error_light)
|
||||
.setLargeIcon(App.getLauncherBitmap(context))
|
||||
.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)
|
||||
.build()
|
||||
val nm = NotificationManagerCompat.from(context)
|
||||
nm.notify(Constants.NOTIFICATION_PERMISSIONS, notify)
|
||||
}
|
||||
|
||||
protected fun checkSyncConditions(settings: AccountSettings): Boolean {
|
||||
@@ -101,17 +79,6 @@ abstract class SyncAdapterService: Service() {
|
||||
}
|
||||
|
||||
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(AccountSettingsActivity.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('"'))) {
|
||||
@@ -123,18 +90,6 @@ abstract class SyncAdapterService: Service() {
|
||||
return true
|
||||
}
|
||||
|
||||
protected fun notifyPermissions(intent: Intent) {
|
||||
val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_ERRORS)
|
||||
.setSmallIcon(R.drawable.ic_sync_problem_notify)
|
||||
.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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -8,28 +8,19 @@
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.database.DatabaseUtils
|
||||
import android.os.Bundle
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.model.Service
|
||||
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.LocalTaskList
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.ical4android.AndroidTaskList
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import okhttp3.HttpUrl
|
||||
import org.dmfs.tasks.contract.TaskContract
|
||||
import org.dmfs.provider.tasks.TaskContract
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
@@ -37,101 +28,96 @@ import java.util.logging.Level
|
||||
*/
|
||||
class TasksSyncAdapterService: SyncAdapterService() {
|
||||
|
||||
override fun syncAdapter() = TasksSyncAdapter(this)
|
||||
override fun syncAdapter() = SyncAdapter(this)
|
||||
|
||||
|
||||
class TasksSyncAdapter(
|
||||
protected class SyncAdapter(
|
||||
context: Context
|
||||
): SyncAdapter(context) {
|
||||
): SyncAdapterService.SyncAdapter(context) {
|
||||
|
||||
override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
try {
|
||||
val taskProvider = TaskProvider.fromProviderClient(context, provider)
|
||||
|
||||
// make sure account can be seen by OpenTasks
|
||||
if (Build.VERSION.SDK_INT >= 26)
|
||||
AccountManager.get(context).setAccountVisibility(account, taskProvider.name.packageName, AccountManager.VISIBILITY_VISIBLE)
|
||||
|
||||
val accountSettings = AccountSettings(context, account)
|
||||
val taskProvider = TaskProvider.fromProviderClient(provider)
|
||||
val settings = 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))
|
||||
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
|
||||
return
|
||||
|
||||
updateLocalTaskLists(taskProvider, account, accountSettings)
|
||||
updateLocalTaskLists(taskProvider, account, settings)
|
||||
|
||||
for (taskList in AndroidTaskList.find(account, taskProvider, LocalTaskList.Factory, "${TaskContract.TaskLists.SYNC_ENABLED}!=0", null)) {
|
||||
Logger.log.info("Synchronizing task list #${taskList.id} [${taskList.syncId}]")
|
||||
TasksSyncManager(context, account, accountSettings, extras, authority, syncResult, taskList).use {
|
||||
it.performSync()
|
||||
}
|
||||
TasksSyncManager(context, account, settings, extras, authority, syncResult, taskProvider, taskList)
|
||||
.performSync()
|
||||
}
|
||||
} catch (e: TaskProvider.ProviderTooOldException) {
|
||||
val nm = NotificationManagerCompat.from(context)
|
||||
val message = context.getString(R.string.sync_error_opentasks_required_version, e.provider.minVersionName, e.installedVersionName)
|
||||
val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_ERRORS)
|
||||
.setSmallIcon(R.drawable.ic_sync_problem_notify)
|
||||
.setContentTitle(context.getString(R.string.sync_error_opentasks_too_old))
|
||||
.setContentText(message)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
|
||||
try {
|
||||
val icon = context.packageManager.getApplicationIcon(e.provider.packageName)
|
||||
if (icon is BitmapDrawable)
|
||||
notify.setLargeIcon(icon.bitmap)
|
||||
} catch(ignored: PackageManager.NameNotFoundException) {}
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${e.provider.packageName}"))
|
||||
if (intent.resolveActivity(context.packageManager) != null)
|
||||
notify .setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.setAutoCancel(true)
|
||||
|
||||
nm.notify(NotificationUtils.NOTIFY_OPENTASKS, notify.build())
|
||||
syncResult.databaseError = true
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't sync task lists", e)
|
||||
syncResult.databaseError = true
|
||||
}
|
||||
|
||||
Logger.log.info("Task sync complete")
|
||||
}
|
||||
|
||||
private fun updateLocalTaskLists(provider: TaskProvider, account: Account, settings: AccountSettings) {
|
||||
val db = AppDatabase.getInstance(context)
|
||||
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV)
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.readableDatabase
|
||||
|
||||
val remoteTaskLists = mutableMapOf<HttpUrl, Collection>()
|
||||
if (service != null)
|
||||
for (collection in db.collectionDao().getSyncTaskLists(service.id)) {
|
||||
remoteTaskLists[collection.url] = collection
|
||||
}
|
||||
fun getService() =
|
||||
db.query(Services._TABLE, arrayOf(Services.ID),
|
||||
"${Services.ACCOUNT_NAME}=? AND ${Services.SERVICE}=?",
|
||||
arrayOf(account.name, Services.SERVICE_CALDAV), null, null, null)?.use { c ->
|
||||
if (c.moveToNext())
|
||||
c.getLong(0)
|
||||
else
|
||||
null
|
||||
}
|
||||
|
||||
// delete/update local task lists
|
||||
val updateColors = settings.getManageCalendarColors()
|
||||
|
||||
for (list in AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null))
|
||||
list.syncId?.let {
|
||||
val url = HttpUrl.parse(it)!!
|
||||
val info = remoteTaskLists[url]
|
||||
if (info == null) {
|
||||
Logger.log.fine("Deleting obsolete local task list $url")
|
||||
list.delete()
|
||||
} else {
|
||||
// remote CollectionInfo found for this local collection, update data
|
||||
Logger.log.log(Level.FINE, "Updating local task list $url", info)
|
||||
list.update(info, updateColors)
|
||||
// we already have a local task list for this remote collection, don't take into consideration anymore
|
||||
remoteTaskLists -= url
|
||||
fun remoteTaskLists(service: Long?): MutableMap<String, CollectionInfo> {
|
||||
val collections = mutableMapOf<String, CollectionInfo>()
|
||||
service?.let {
|
||||
db.query(Collections._TABLE, null,
|
||||
"${Collections.SERVICE_ID}=? AND ${Collections.SUPPORTS_VTODO}!=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
|
||||
}
|
||||
|
||||
// create new local task lists
|
||||
for ((_,info) in remoteTaskLists) {
|
||||
Logger.log.log(Level.INFO, "Adding local task list", info)
|
||||
LocalTaskList.create(account, provider, info)
|
||||
// enumerate remote and local task lists
|
||||
val service = getService()
|
||||
val remote = remoteTaskLists(service)
|
||||
|
||||
// delete/update local task lists
|
||||
val updateColors = settings.getManageCalendarColors()
|
||||
|
||||
for (list in AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null))
|
||||
list.syncId?.let { url ->
|
||||
val info = remote[url]
|
||||
if (info == null) {
|
||||
Logger.log.fine("Deleting obsolete local task list $url")
|
||||
list.delete()
|
||||
} else {
|
||||
// remote CollectionInfo found for this local collection, update data
|
||||
Logger.log.log(Level.FINE, "Updating local task list $url", info)
|
||||
list.update(info, updateColors)
|
||||
// we already have a local task list for this remote collection, don't take into consideration anymore
|
||||
remote -= url
|
||||
}
|
||||
}
|
||||
|
||||
// create new local task lists
|
||||
for ((_,info) in remote) {
|
||||
Logger.log.log(Level.INFO, "Adding local task list", info)
|
||||
LocalTaskList.create(account, provider, info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,135 +12,158 @@ 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.CalendarData
|
||||
import at.bitfire.dav4jvm.property.GetCTag
|
||||
import at.bitfire.dav4jvm.property.GetETag
|
||||
import at.bitfire.dav4jvm.property.SyncToken
|
||||
import at.bitfire.davdroid.DavUtils
|
||||
import at.bitfire.dav4android.DavCalendar
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.exception.DavException
|
||||
import at.bitfire.dav4android.property.CalendarData
|
||||
import at.bitfire.dav4android.property.GetCTag
|
||||
import at.bitfire.dav4android.property.GetETag
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.resource.LocalTask
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.ical4android.InvalidCalendarException
|
||||
import at.bitfire.ical4android.Task
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.RequestBody
|
||||
import org.apache.commons.collections4.ListUtils
|
||||
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 tasks (VTODO)
|
||||
*/
|
||||
class TasksSyncManager(
|
||||
context: Context,
|
||||
account: Account,
|
||||
accountSettings: AccountSettings,
|
||||
settings: AccountSettings,
|
||||
extras: Bundle,
|
||||
authority: String,
|
||||
syncResult: SyncResult,
|
||||
localCollection: LocalTaskList
|
||||
): SyncManager<LocalTask, LocalTaskList, DavCalendar>(context, account, accountSettings, extras, authority, syncResult, localCollection) {
|
||||
val provider: TaskProvider,
|
||||
val localTaskList: LocalTaskList
|
||||
): SyncManager(context, account, settings, extras, authority, syncResult, "taskList/${localTaskList.id}") {
|
||||
|
||||
val MAX_MULTIGET = 30
|
||||
|
||||
|
||||
init {
|
||||
localCollection = localTaskList
|
||||
}
|
||||
|
||||
override fun notificationId() = Constants.NOTIFICATION_TASK_SYNC
|
||||
|
||||
override fun getSyncErrorTitle() = context.getString(R.string.sync_error_tasks, account.name)!!
|
||||
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
collectionURL = HttpUrl.parse(localCollection.syncId ?: return false) ?: return false
|
||||
davCollection = DavCalendar(httpClient.okHttpClient, collectionURL)
|
||||
|
||||
collectionURL = HttpUrl.parse(localTaskList.syncId ?: return false) ?: return false
|
||||
davCollection = DavCalendar(httpClient, collectionURL)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun queryCapabilities() =
|
||||
useRemoteCollection {
|
||||
var syncState: SyncState? = null
|
||||
it.propfind(0, GetCTag.NAME, SyncToken.NAME) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF)
|
||||
syncState = syncState(response)
|
||||
}
|
||||
syncState
|
||||
}
|
||||
|
||||
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
|
||||
|
||||
override fun prepareUpload(resource: LocalTask): RequestBody = useLocal(resource) {
|
||||
val task = requireNotNull(resource.task)
|
||||
Logger.log.log(Level.FINE, "Preparing upload of task ${resource.fileName}", task)
|
||||
|
||||
val os = ByteArrayOutputStream()
|
||||
task.write(os)
|
||||
|
||||
RequestBody.create(
|
||||
DavCalendar.MIME_ICALENDAR_UTF8,
|
||||
os.toByteArray()
|
||||
)
|
||||
override fun queryCapabilities() {
|
||||
davCollection.propfind(0, GetCTag.NAME)
|
||||
}
|
||||
|
||||
override fun listAllRemote(callback: DavResponseCallback) {
|
||||
useRemoteCollection { remote ->
|
||||
Logger.log.info("Querying tasks")
|
||||
remote.calendarQuery("VTODO", null, null, callback)
|
||||
override fun prepareUpload(resource: LocalResource): RequestBody {
|
||||
if (resource is LocalTask) {
|
||||
val task = requireNotNull(resource.task)
|
||||
Logger.log.log(Level.FINE, "Preparing upload of task ${resource.fileName}", task)
|
||||
|
||||
val os = ByteArrayOutputStream()
|
||||
task.write(os)
|
||||
|
||||
return RequestBody.create(
|
||||
DavCalendar.MIME_ICALENDAR,
|
||||
os.toByteArray()
|
||||
)
|
||||
} else
|
||||
throw IllegalArgumentException("resource must be a LocalTask")
|
||||
}
|
||||
|
||||
override fun listRemote() {
|
||||
// fetch list of remote VTODOs and build hash table to index file name
|
||||
val calendar = davCalendar()
|
||||
currentDavResource = calendar
|
||||
calendar.calendarQuery("VTODO", null, null)
|
||||
|
||||
remoteResources = HashMap<String, DavResource>(davCollection.members.size)
|
||||
for (vCard in davCollection.members) {
|
||||
val fileName = vCard.fileName()
|
||||
Logger.log.fine("Found remote VTODO: $fileName")
|
||||
remoteResources[fileName] = vCard
|
||||
}
|
||||
|
||||
currentDavResource = null
|
||||
}
|
||||
|
||||
override fun downloadRemote() {
|
||||
Logger.log.info("Downloading ${toDownload.size} tasks ($MAX_MULTIGET at once)")
|
||||
|
||||
// download new/updated iCalendars from server
|
||||
for (bunch in ListUtils.partition(toDownload.toList(), MAX_MULTIGET)) {
|
||||
if (Thread.interrupted())
|
||||
return
|
||||
|
||||
Logger.log.info("Downloading ${bunch.joinToString(", ")}")
|
||||
|
||||
if (bunch.size == 1) {
|
||||
// only one contact, use GET
|
||||
val remote = bunch.first()
|
||||
currentDavResource = remote
|
||||
|
||||
val body = remote.get("text/calendar")
|
||||
|
||||
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
|
||||
val eTag = remote.properties[GetETag.NAME] as GetETag?
|
||||
if (eTag == null || eTag.eTag.isNullOrEmpty())
|
||||
throw DavException("Received CalDAV GET response without ETag for ${remote.location}")
|
||||
|
||||
body.charStream().use { reader ->
|
||||
processVTodo(remote.fileName(), eTag.eTag!!, reader)
|
||||
}
|
||||
|
||||
} else {
|
||||
// multiple contacts, use multi-get
|
||||
val calendar = davCalendar()
|
||||
currentDavResource = calendar
|
||||
calendar.multiget(bunch.map { it.location })
|
||||
|
||||
// process multiget results
|
||||
for (remote in davCollection.members) {
|
||||
currentDavResource = remote
|
||||
|
||||
val eTag = (remote.properties[GetETag.NAME] as GetETag?)?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
|
||||
val calendarData = remote.properties[CalendarData.NAME] as CalendarData?
|
||||
val iCalendar = calendarData?.iCalendar
|
||||
?: throw DavException("Received multi-get response without task data")
|
||||
|
||||
processVTodo(remote.fileName(), eTag, StringReader(iCalendar))
|
||||
}
|
||||
}
|
||||
|
||||
currentDavResource = null
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
processVTodo(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")
|
||||
|
||||
processVTodo(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(iCal))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun postProcess() {
|
||||
}
|
||||
|
||||
// helpers
|
||||
|
||||
private fun davCalendar() = davCollection as DavCalendar
|
||||
|
||||
private fun processVTodo(fileName: String, eTag: String, reader: Reader) {
|
||||
val tasks: List<Task>
|
||||
try {
|
||||
tasks = Task.fromReader(reader)
|
||||
} catch (e: InvalidCalendarException) {
|
||||
Logger.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e)
|
||||
notifyInvalidResource(e, fileName)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -148,25 +171,22 @@ class TasksSyncManager(
|
||||
val newData = tasks.first()
|
||||
|
||||
// update local task, if it exists
|
||||
useLocal(localCollection.findByName(fileName)) { local ->
|
||||
if (local != null) {
|
||||
Logger.log.log(Level.INFO, "Updating $fileName in local task list", newData)
|
||||
local.eTag = eTag
|
||||
local.update(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
} else {
|
||||
Logger.log.log(Level.INFO, "Adding $fileName to local task list", newData)
|
||||
useLocal(LocalTask(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) {
|
||||
it.add()
|
||||
}
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
val localTask = localResources[fileName] as LocalTask?
|
||||
currentLocalResource = localTask
|
||||
if (localTask != null) {
|
||||
Logger.log.info("Updating $fileName in local tasklist")
|
||||
localTask.eTag = eTag
|
||||
localTask.update(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
} else {
|
||||
Logger.log.info("Adding $fileName to local task list")
|
||||
val newTask = LocalTask(localTaskList, newData, fileName, eTag)
|
||||
currentLocalResource = newTask
|
||||
newTask.add()
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
} else
|
||||
Logger.log.info("Received VCALENDAR with not exactly one VTODO; ignoring $fileName")
|
||||
Logger.log.severe("Received VCALENDAR with not exactly one VTODO; ignoring $fileName")
|
||||
}
|
||||
|
||||
override fun notifyInvalidResourceTitle(): String =
|
||||
context.getString(R.string.sync_invalid_task)
|
||||
|
||||
}
|
||||
}
|
||||
@@ -8,61 +8,83 @@
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.Fragment
|
||||
import android.support.v4.app.FragmentManager
|
||||
import android.support.v4.app.FragmentPagerAdapter
|
||||
import android.support.v4.app.LoaderManager
|
||||
import android.support.v4.content.AsyncTaskLoader
|
||||
import android.support.v4.content.Loader
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.text.Html
|
||||
import android.text.Spanned
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.*
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.FragmentPagerAdapter
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import at.bitfire.davdroid.App
|
||||
import android.text.util.Linkify
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.R
|
||||
import com.mikepenz.aboutlibraries.LibsBuilder
|
||||
import kotlinx.android.synthetic.main.about.*
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import ezvcard.Ezvcard
|
||||
import kotlinx.android.synthetic.main.about_component.view.*
|
||||
import kotlinx.android.synthetic.main.activity_about.*
|
||||
import org.apache.commons.io.IOUtils
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.concurrent.thread
|
||||
import java.io.IOException
|
||||
import java.util.logging.Level
|
||||
|
||||
class AboutActivity: AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
|
||||
const val pixelsHtml = "<font color=\"#fff433\">■</font>" +
|
||||
"<font color=\"#ffffff\">■</font>" +
|
||||
"<font color=\"#9b59d0\">■</font>" +
|
||||
"<font color=\"#000000\">■</font>"
|
||||
|
||||
}
|
||||
private class ComponentInfo(
|
||||
val title: String?,
|
||||
val version: String?,
|
||||
val website: String,
|
||||
val copyright: String,
|
||||
val licenseInfo: Int?,
|
||||
val licenseTextFile: String?
|
||||
)
|
||||
private lateinit var components: Array<ComponentInfo>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
components = arrayOf(
|
||||
ComponentInfo(
|
||||
null, BuildConfig.VERSION_NAME, getString(R.string.homepage_url),
|
||||
"Ricki Hirner, Bernhard Stockmann (bitfire web engineering)",
|
||||
null, null
|
||||
), ComponentInfo(
|
||||
"AmbilWarna", null, "https://github.com/yukuku/ambilwarna",
|
||||
"Yuku", R.string.about_license_info_no_warranty, "apache2.html"
|
||||
), ComponentInfo(
|
||||
"Apache Commons", null, "http://commons.apache.org/",
|
||||
"Apache Software Foundation", R.string.about_license_info_no_warranty, "apache2.html"
|
||||
), ComponentInfo(
|
||||
"dnsjava", null, "http://dnsjava.org/",
|
||||
"Brian Wellington", R.string.about_license_info_no_warranty, "bsd.html"
|
||||
), ComponentInfo(
|
||||
"ez-vcard", Ezvcard.VERSION, "https://github.com/mangstadt/ez-vcard",
|
||||
"Michael Angstadt", R.string.about_license_info_no_warranty, "bsd.html"
|
||||
), ComponentInfo(
|
||||
"ical4j", "2.x", "https://ical4j.github.io/",
|
||||
"Ben Fortuna", R.string.about_license_info_no_warranty, "bsd-3clause.html"
|
||||
), ComponentInfo(
|
||||
"OkHttp", null, "https://square.github.io/okhttp/",
|
||||
"Square, Inc.", R.string.about_license_info_no_warranty, "apache2.html"
|
||||
), ComponentInfo(
|
||||
"Project Lombok", null, "https://projectlombok.org/",
|
||||
"The Project Lombok Authors", R.string.about_license_info_no_warranty, "mit.html"
|
||||
)
|
||||
)
|
||||
|
||||
setContentView(R.layout.activity_about)
|
||||
|
||||
setSupportActionBar(toolbar)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
viewpager.adapter = TabsAdapter(supportFragmentManager)
|
||||
tabs.setupWithViewPager(viewpager, false)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
menuInflater.inflate(R.menu.about_davdroid, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
fun showWebsite(item: MenuItem) {
|
||||
UiUtils.launchUri(this, App.homepageUrl(this))
|
||||
tabs.setupWithViewPager(viewpager)
|
||||
}
|
||||
|
||||
|
||||
@@ -70,68 +92,101 @@ class AboutActivity: AppCompatActivity() {
|
||||
fm: FragmentManager
|
||||
): FragmentPagerAdapter(fm) {
|
||||
|
||||
override fun getCount() = 2
|
||||
override fun getCount() = components.size
|
||||
|
||||
override fun getPageTitle(position: Int): String =
|
||||
when (position) {
|
||||
0 -> getString(R.string.app_name)
|
||||
else -> getString(R.string.about_libraries)
|
||||
}
|
||||
override fun getPageTitle(position: Int) =
|
||||
components[position].title ?: getString(R.string.app_name)!!
|
||||
|
||||
override fun getItem(position: Int) =
|
||||
when (position) {
|
||||
0 -> AppFragment()
|
||||
else -> LibsBuilder()
|
||||
.withAutoDetect(false)
|
||||
.withFields(R.string::class.java.fields)
|
||||
.withLicenseShown(true)
|
||||
.supportFragment()
|
||||
}!!
|
||||
override fun getItem(position: Int) = ComponentFragment.instantiate(position)
|
||||
}
|
||||
|
||||
class ComponentFragment: Fragment(), LoaderManager.LoaderCallbacks<Spanned> {
|
||||
|
||||
class AppFragment: Fragment() {
|
||||
companion object {
|
||||
val KEY_POSITION = "position"
|
||||
val KEY_FILE_NAME = "fileName"
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) =
|
||||
inflater.inflate(R.layout.about, container, false)!!
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
app_name.text = getString(R.string.app_name)
|
||||
app_version.text = getString(R.string.about_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE)
|
||||
build_time.text = getString(R.string.about_build_date, SimpleDateFormat.getDateInstance().format(BuildConfig.buildTime))
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
|
||||
icon.setImageDrawable(resources.getDrawableForDensity(R.mipmap.ic_launcher, DisplayMetrics.DENSITY_XXXHIGH))
|
||||
|
||||
pixels.text = HtmlCompat.fromHtml(pixelsHtml, HtmlCompat.FROM_HTML_MODE_LEGACY)
|
||||
|
||||
if (true /* open-source version */) {
|
||||
warranty.setText(R.string.about_license_info_no_warranty)
|
||||
|
||||
val model = ViewModelProviders.of(this).get(LicenseModel::class.java)
|
||||
model.htmlText.observe(this, Observer { spanned ->
|
||||
license_text.text = spanned
|
||||
})
|
||||
fun instantiate(position: Int): ComponentFragment {
|
||||
val frag = ComponentFragment()
|
||||
val args = Bundle(1)
|
||||
args.putInt(KEY_POSITION, position)
|
||||
frag.arguments = args
|
||||
return frag
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val info = (activity as AboutActivity).components[arguments.getInt(KEY_POSITION)]
|
||||
|
||||
class LicenseModel(
|
||||
application: Application
|
||||
): AndroidViewModel(application) {
|
||||
val v = inflater.inflate(R.layout.about_component, container, false)
|
||||
|
||||
val htmlText = MutableLiveData<Spanned>()
|
||||
var title = info.title ?: getString(R.string.app_name)
|
||||
info.version?.let { title += " ${info.version}" }
|
||||
v.title.text = title
|
||||
|
||||
init {
|
||||
thread {
|
||||
getApplication<Application>().resources.assets.open("gplv3.html").use {
|
||||
val spanned = HtmlCompat.fromHtml(IOUtils.toString(it, Charsets.UTF_8), HtmlCompat.FROM_HTML_MODE_LEGACY)
|
||||
htmlText.postValue(spanned)
|
||||
}
|
||||
v.website.autoLinkMask = Linkify.WEB_URLS
|
||||
v.website.text = info.website
|
||||
|
||||
v.copyright.text = "© ${info.copyright}"
|
||||
|
||||
if (info.licenseInfo == null)
|
||||
v.license_info.visibility = View.GONE
|
||||
else
|
||||
v.license_info.setText(info.licenseInfo)
|
||||
|
||||
// load and format license text
|
||||
if (info.licenseTextFile == null) {
|
||||
v.license_header.visibility = View.GONE
|
||||
v.license_text.visibility = View.GONE
|
||||
} else {
|
||||
val args = Bundle(1)
|
||||
args.putString(KEY_FILE_NAME, info.licenseTextFile)
|
||||
loaderManager.initLoader(0, args, this)
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle) =
|
||||
LicenseLoader(context, args.getString(KEY_FILE_NAME))
|
||||
|
||||
override fun onLoadFinished(loader: Loader<Spanned>, license: Spanned?) {
|
||||
view?.let { v ->
|
||||
v.license_text.autoLinkMask = Linkify.EMAIL_ADDRESSES or Linkify.WEB_URLS
|
||||
v.license_text.text = license
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<Spanned>) {}
|
||||
}
|
||||
|
||||
class LicenseLoader(
|
||||
context: Context,
|
||||
val fileName: String
|
||||
): AsyncTaskLoader<Spanned>(context) {
|
||||
|
||||
var content: Spanned? = null
|
||||
|
||||
override fun onStartLoading() {
|
||||
if (content == null)
|
||||
forceLoad()
|
||||
else
|
||||
deliverResult(content)
|
||||
}
|
||||
|
||||
override fun loadInBackground(): Spanned? {
|
||||
Logger.log.fine("Loading license file $fileName")
|
||||
try {
|
||||
context.resources.assets.open(fileName).use {
|
||||
content = Html.fromHtml(IOUtils.toString(it, Charsets.UTF_8))
|
||||
return content
|
||||
}
|
||||
} catch(e: IOException) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't read license file", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.app.AlertDialog
|
||||
import android.app.Dialog
|
||||
import android.app.LoaderManager
|
||||
import android.content.*
|
||||
import android.database.DatabaseUtils
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.support.design.widget.Snackbar
|
||||
import android.support.v4.app.DialogFragment
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.support.v7.widget.Toolbar
|
||||
import android.view.*
|
||||
import android.widget.*
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.CustomCertificates
|
||||
import at.bitfire.davdroid.DavService
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.ServiceDB.*
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import kotlinx.android.synthetic.main.activity_account.*
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, PopupMenu.OnMenuItemClickListener, LoaderManager.LoaderCallbacks<AccountActivity.AccountInfo> {
|
||||
|
||||
companion object {
|
||||
@JvmField val EXTRA_ACCOUNT = "account"
|
||||
|
||||
private fun requestSync(account: Account) {
|
||||
val authorities = arrayOf(
|
||||
App.addressBooksAuthority,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
lateinit var account: Account
|
||||
var accountInfo: AccountInfo? = null
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
account = intent.getParcelableExtra(EXTRA_ACCOUNT)
|
||||
title = account.name
|
||||
|
||||
setContentView(R.layout.activity_account)
|
||||
|
||||
val icMenu = if (Build.VERSION.SDK_INT >= 21)
|
||||
getDrawable(R.drawable.ic_menu_light)
|
||||
else
|
||||
resources.getDrawable(R.drawable.ic_menu_light)
|
||||
|
||||
// CardDAV toolbar
|
||||
carddav_menu.overflowIcon = icMenu
|
||||
carddav_menu.inflateMenu(R.menu.carddav_actions)
|
||||
carddav_menu.setOnMenuItemClickListener(this)
|
||||
|
||||
// CalDAV toolbar
|
||||
caldav_menu.overflowIcon = icMenu
|
||||
caldav_menu.inflateMenu(R.menu.caldav_actions)
|
||||
caldav_menu.setOnMenuItemClickListener(this)
|
||||
|
||||
// load CardDAV/CalDAV collections
|
||||
loaderManager.initLoader(0, intent.extras, this)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
CustomCertificates.certManager?.let { it.appInForeground = false }
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
CustomCertificates.certManager?.let { it.appInForeground = true }
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.activity_account, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
|
||||
val itemRename = menu.findItem(R.id.rename_account)
|
||||
// renameAccount is available for API level 21+
|
||||
itemRename.isVisible = Build.VERSION.SDK_INT >= 21
|
||||
return super.onPrepareOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.sync_now ->
|
||||
requestSync()
|
||||
R.id.settings -> {
|
||||
val intent = Intent(this, AccountSettingsActivity::class.java)
|
||||
intent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account)
|
||||
startActivity(intent)
|
||||
}
|
||||
R.id.rename_account ->
|
||||
RenameAccountFragment.newInstance(account).show(supportFragmentManager, null)
|
||||
R.id.delete_account -> {
|
||||
AlertDialog.Builder(this)
|
||||
.setIcon(R.drawable.ic_error_dark)
|
||||
.setTitle(R.string.account_delete_confirmation_title)
|
||||
.setMessage(R.string.account_delete_confirmation_text)
|
||||
.setNegativeButton(android.R.string.no, null)
|
||||
.setPositiveButton(android.R.string.yes, { _, _ ->
|
||||
deleteAccount()
|
||||
})
|
||||
.show()
|
||||
}
|
||||
else ->
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.refresh_address_books ->
|
||||
accountInfo?.carddav?.let { carddav ->
|
||||
val intent = Intent(this, DavService::class.java)
|
||||
intent.action = DavService.ACTION_REFRESH_COLLECTIONS
|
||||
intent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, carddav.id)
|
||||
startService(intent)
|
||||
}
|
||||
R.id.create_address_book -> {
|
||||
val intent = Intent(this, CreateAddressBookActivity::class.java)
|
||||
intent.putExtra(CreateAddressBookActivity.EXTRA_ACCOUNT, account)
|
||||
startActivity(intent)
|
||||
}
|
||||
R.id.refresh_calendars ->
|
||||
accountInfo?.caldav?.let { caldav ->
|
||||
val intent = Intent(this, DavService::class.java)
|
||||
intent.action = DavService.ACTION_REFRESH_COLLECTIONS
|
||||
intent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, caldav.id)
|
||||
startService(intent)
|
||||
}
|
||||
R.id.create_calendar -> {
|
||||
val intent = Intent(this, CreateCalendarActivity::class.java)
|
||||
intent.putExtra(CreateCalendarActivity.EXTRA_ACCOUNT, account)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
private val onItemClickListener = AdapterView.OnItemClickListener { parent, _, position, _ ->
|
||||
val list = parent as ListView
|
||||
val adapter = list.adapter as ArrayAdapter<CollectionInfo>
|
||||
val info = adapter.getItem(position)
|
||||
|
||||
val nowChecked = !info.selected
|
||||
|
||||
OpenHelper(this@AccountActivity).use { dbHelper ->
|
||||
val db = dbHelper.writableDatabase
|
||||
db.beginTransactionNonExclusive()
|
||||
|
||||
val values = ContentValues(1)
|
||||
values.put(Collections.SYNC, if (nowChecked) 1 else 0)
|
||||
db.update(Collections._TABLE, values, "${Collections.ID}=?", arrayOf(info.id.toString()))
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
db.endTransaction()
|
||||
|
||||
info.selected = nowChecked
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private val onItemLongClickListener = AdapterView.OnItemLongClickListener { parent, view, position, _ ->
|
||||
val list = parent as ListView
|
||||
val adapter = list.adapter as ArrayAdapter<CollectionInfo>
|
||||
val info = adapter.getItem(position)
|
||||
|
||||
val popup = PopupMenu(this@AccountActivity, view)
|
||||
popup.inflate(R.menu.account_collection_operations)
|
||||
popup.setOnMenuItemClickListener({ item ->
|
||||
when (item.itemId) {
|
||||
R.id.delete_collection ->
|
||||
DeleteCollectionFragment.ConfirmDeleteCollectionFragment.newInstance(account, info).show(getSupportFragmentManager(), null);
|
||||
}
|
||||
true
|
||||
})
|
||||
popup.show()
|
||||
|
||||
// long click was handled
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
/* LOADERS AND LOADED DATA */
|
||||
|
||||
class AccountInfo {
|
||||
var carddav: ServiceInfo? = null
|
||||
var caldav: ServiceInfo? = null
|
||||
|
||||
class ServiceInfo {
|
||||
var id: Long? = null
|
||||
var refreshing = false
|
||||
|
||||
var hasHomeSets = false
|
||||
var collections = listOf<CollectionInfo>()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) =
|
||||
AccountLoader(this, account)
|
||||
|
||||
fun reload() {
|
||||
loaderManager.restartLoader(0, intent.extras, this)
|
||||
}
|
||||
|
||||
override fun onLoadFinished(loader: Loader<AccountInfo>, info: AccountInfo?) {
|
||||
accountInfo = info
|
||||
|
||||
carddav.visibility = info?.carddav?.let { carddav ->
|
||||
carddav_refreshing.visibility = if (carddav.refreshing) View.VISIBLE else View.GONE
|
||||
|
||||
address_books.isEnabled = !carddav.refreshing
|
||||
address_books.alpha = if (carddav.refreshing) 0.5f else 1f
|
||||
|
||||
carddav_menu.menu.findItem(R.id.create_address_book).isEnabled = carddav.hasHomeSets
|
||||
|
||||
val adapter = AddressBookAdapter(this)
|
||||
adapter.addAll(carddav.collections)
|
||||
address_books.adapter = adapter
|
||||
address_books.onItemClickListener = onItemClickListener
|
||||
address_books.onItemLongClickListener = onItemLongClickListener
|
||||
|
||||
View.VISIBLE
|
||||
} ?: View.GONE
|
||||
|
||||
caldav.visibility = info?.caldav?.let { caldav ->
|
||||
caldav_refreshing.visibility = if (caldav.refreshing) View.VISIBLE else View.GONE
|
||||
|
||||
calendars.isEnabled = !caldav.refreshing
|
||||
calendars.alpha = if (caldav.refreshing) 0.5f else 1f
|
||||
|
||||
caldav_menu.menu.findItem(R.id.create_calendar).isEnabled = caldav.hasHomeSets
|
||||
|
||||
val adapter = CalendarAdapter(this)
|
||||
adapter.addAll(caldav.collections)
|
||||
calendars.adapter = adapter
|
||||
calendars.onItemClickListener = onItemClickListener
|
||||
calendars.onItemLongClickListener = onItemLongClickListener
|
||||
|
||||
View.VISIBLE
|
||||
} ?: View.GONE
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<AccountInfo>) {
|
||||
address_books?.adapter = null
|
||||
calendars?.adapter = null
|
||||
}
|
||||
|
||||
|
||||
class AccountLoader(
|
||||
context: Context,
|
||||
val account: Account
|
||||
): AsyncTaskLoader<AccountInfo>(context), DavService.RefreshingStatusListener, ServiceConnection, SyncStatusObserver {
|
||||
|
||||
private var davService: DavService.InfoBinder? = null
|
||||
private lateinit var syncStatusListener: Any
|
||||
|
||||
override fun onStartLoading() {
|
||||
syncStatusListener = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE, this)
|
||||
context.bindService(Intent(context, DavService::class.java), this, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
|
||||
override fun onStopLoading() {
|
||||
davService?.removeRefreshingStatusListener(this)
|
||||
context.unbindService(this)
|
||||
ContentResolver.removeStatusChangeListener(syncStatusListener)
|
||||
}
|
||||
|
||||
override fun onServiceConnected(name: ComponentName, service: IBinder) {
|
||||
davService = service as DavService.InfoBinder
|
||||
service.addRefreshingStatusListener(this, false)
|
||||
|
||||
forceLoad()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName) {
|
||||
davService = null
|
||||
}
|
||||
|
||||
override fun onDavRefreshStatusChanged(id: Long, refreshing: Boolean) =
|
||||
forceLoad()
|
||||
|
||||
override fun onStatusChanged(which: Int) =
|
||||
forceLoad()
|
||||
|
||||
override fun loadInBackground(): AccountInfo {
|
||||
val info = AccountInfo()
|
||||
|
||||
OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.readableDatabase
|
||||
db.query(
|
||||
Services._TABLE,
|
||||
arrayOf(Services.ID, Services.SERVICE),
|
||||
"${Services.ACCOUNT_NAME}=?", arrayOf(account.name),
|
||||
null, null, null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(0)
|
||||
when (cursor.getString(1)) {
|
||||
Services.SERVICE_CARDDAV -> {
|
||||
val carddav = AccountInfo.ServiceInfo()
|
||||
info.carddav = carddav
|
||||
carddav.id = id
|
||||
carddav.refreshing =
|
||||
davService?.isRefreshing(id) ?: false ||
|
||||
ContentResolver.isSyncActive(account, App.addressBooksAuthority)
|
||||
|
||||
val accountManager = AccountManager.get(context)
|
||||
for (addrBookAccount in accountManager.getAccountsByType(App.addressBookAccountType)) {
|
||||
val addressBook = LocalAddressBook(context, addrBookAccount, null)
|
||||
try {
|
||||
if (account == addressBook.getMainAccount())
|
||||
carddav.refreshing = carddav.refreshing || ContentResolver.isSyncActive(addrBookAccount, ContactsContract.AUTHORITY)
|
||||
} catch(e: ContactsStorageException) {
|
||||
}
|
||||
}
|
||||
|
||||
carddav.hasHomeSets = hasHomeSets(db, id)
|
||||
carddav.collections = readCollections(db, id)
|
||||
}
|
||||
Services.SERVICE_CALDAV -> {
|
||||
val caldav = AccountInfo.ServiceInfo()
|
||||
info.caldav = caldav
|
||||
caldav.id = id
|
||||
caldav.refreshing =
|
||||
davService?.isRefreshing(id) ?: false ||
|
||||
ContentResolver.isSyncActive(account, CalendarContract.AUTHORITY) ||
|
||||
ContentResolver.isSyncActive(account, TaskProvider.ProviderName.OpenTasks.authority)
|
||||
caldav.hasHomeSets = hasHomeSets(db, id)
|
||||
caldav.collections = readCollections(db, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
private fun hasHomeSets(db: SQLiteDatabase, service: Long): Boolean {
|
||||
db.query(ServiceDB.HomeSets._TABLE, null, "${ServiceDB.HomeSets.SERVICE_ID}=?",
|
||||
arrayOf(service.toString()), null, null, null)?.use { cursor ->
|
||||
return cursor.count > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun readCollections(db: SQLiteDatabase, service: Long): List<CollectionInfo> {
|
||||
val collections = LinkedList<CollectionInfo>()
|
||||
db.query(Collections._TABLE, null, Collections.SERVICE_ID + "=?", arrayOf(service.toString()),
|
||||
null, null, "${Collections.SUPPORTS_VEVENT} DESC,${Collections.DISPLAY_NAME}").use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val values = ContentValues(cursor.columnCount)
|
||||
DatabaseUtils.cursorRowToContentValues(cursor, values)
|
||||
collections.add(CollectionInfo(values))
|
||||
}
|
||||
}
|
||||
return collections
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/* LIST ADAPTERS */
|
||||
|
||||
class AddressBookAdapter(
|
||||
context: Context
|
||||
): ArrayAdapter<CollectionInfo>(context, R.layout.account_carddav_item) {
|
||||
|
||||
override fun getView(position: Int, v: View?, parent: ViewGroup?): View {
|
||||
val v = v ?: LayoutInflater.from(context).inflate(R.layout.account_carddav_item, parent, false)
|
||||
val info = getItem(position)
|
||||
|
||||
val checked: CheckBox = v.findViewById(R.id.checked)
|
||||
checked.isChecked = info.selected
|
||||
|
||||
var tv: TextView = v.findViewById(R.id.title)
|
||||
tv.text = if (!info.displayName.isNullOrBlank()) info.displayName else info.url
|
||||
|
||||
tv = v.findViewById(R.id.description)
|
||||
if (info.description.isNullOrBlank())
|
||||
tv.visibility = View.GONE
|
||||
else {
|
||||
tv.visibility = View.VISIBLE
|
||||
tv.text = info.description
|
||||
}
|
||||
|
||||
tv = v.findViewById(R.id.read_only)
|
||||
tv.visibility = if (info.readOnly) View.VISIBLE else View.GONE
|
||||
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
class CalendarAdapter(
|
||||
context: Context
|
||||
): ArrayAdapter<CollectionInfo>(context, R.layout.account_caldav_item) {
|
||||
|
||||
override fun getView(position: Int, v: View?, parent: ViewGroup?): View {
|
||||
val v = v ?: LayoutInflater.from(context).inflate(R.layout.account_caldav_item, parent, false)
|
||||
val info = getItem(position)
|
||||
|
||||
val checked: CheckBox = v.findViewById(R.id.checked)
|
||||
checked.isChecked = info.selected
|
||||
|
||||
val vColor: View = v.findViewById(R.id.color)
|
||||
vColor.visibility = info.color?.let {
|
||||
vColor.setBackgroundColor(it)
|
||||
View.VISIBLE
|
||||
} ?: View.GONE
|
||||
|
||||
var tv: TextView = v.findViewById(R.id.title)
|
||||
tv.text = if (!info.displayName.isNullOrBlank()) info.displayName else info.url
|
||||
|
||||
tv = v.findViewById(R.id.description)
|
||||
if (info.description.isNullOrBlank())
|
||||
tv.visibility = View.GONE
|
||||
else {
|
||||
tv.visibility = View.VISIBLE
|
||||
tv.text = info.description
|
||||
}
|
||||
|
||||
tv = v.findViewById(R.id.read_only)
|
||||
tv.visibility = if (info.readOnly) View.VISIBLE else View.GONE
|
||||
|
||||
tv = v.findViewById(R.id.events)
|
||||
tv.visibility = if (info.supportsVEVENT) View.VISIBLE else View.GONE
|
||||
|
||||
tv = v.findViewById(R.id.tasks)
|
||||
tv.visibility = if (info.supportsVTODO) View.VISIBLE else View.GONE
|
||||
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* DIALOG FRAGMENTS */
|
||||
|
||||
class RenameAccountFragment: DialogFragment() {
|
||||
|
||||
companion object {
|
||||
|
||||
val ARG_ACCOUNT = "account"
|
||||
|
||||
fun newInstance(account: Account): RenameAccountFragment {
|
||||
val fragment = RenameAccountFragment()
|
||||
val args = Bundle(1)
|
||||
args.putParcelable(ARG_ACCOUNT, account)
|
||||
fragment.arguments = args
|
||||
return fragment
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val oldAccount: Account = arguments.getParcelable(ARG_ACCOUNT)
|
||||
|
||||
val editText = EditText(activity)
|
||||
editText.setText(oldAccount.name)
|
||||
|
||||
return AlertDialog.Builder(activity)
|
||||
.setTitle(R.string.account_rename)
|
||||
.setMessage(R.string.account_rename_new_name)
|
||||
.setView(editText)
|
||||
.setPositiveButton(R.string.account_rename_rename, DialogInterface.OnClickListener { dialog, which ->
|
||||
val newName = editText.text.toString()
|
||||
|
||||
if (newName == oldAccount.name)
|
||||
return@OnClickListener
|
||||
|
||||
val accountManager = AccountManager.get(activity)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
|
||||
accountManager.renameAccount(oldAccount, newName, { _ ->
|
||||
Logger.log.info("Updating account name references")
|
||||
|
||||
// cancel maybe running synchronization
|
||||
ContentResolver.cancelSync(oldAccount, null)
|
||||
for (addrBookAccount in accountManager.getAccountsByType(App.addressBookAccountType))
|
||||
ContentResolver.cancelSync(addrBookAccount, null)
|
||||
|
||||
// update account name references in database
|
||||
OpenHelper(activity).use { dbHelper ->
|
||||
ServiceDB.onRenameAccount(dbHelper.getWritableDatabase(), oldAccount.name, newName)
|
||||
}
|
||||
|
||||
// update main account of address book accounts
|
||||
try {
|
||||
for (addrBookAccount in accountManager.getAccountsByType(App.addressBookAccountType)) {
|
||||
val provider = activity.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
|
||||
try {
|
||||
if (provider != null) {
|
||||
val addressBook = LocalAddressBook(activity, addrBookAccount, provider)
|
||||
if (oldAccount == addressBook.getMainAccount())
|
||||
addressBook.setMainAccount(Account(newName, oldAccount.type))
|
||||
}
|
||||
} finally {
|
||||
if (Build.VERSION.SDK_INT >= 24)
|
||||
provider?.close()
|
||||
else
|
||||
provider?.release()
|
||||
}
|
||||
|
||||
}
|
||||
} catch(e: ContactsStorageException) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't update address book accounts", e)
|
||||
}
|
||||
|
||||
// calendar provider doesn't allow changing account_name of Events
|
||||
// (all events will have to be downloaded again)
|
||||
|
||||
// update account_name of local tasks
|
||||
try {
|
||||
LocalTaskList.onRenameAccount(activity.contentResolver, oldAccount.name, newName)
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't propagate new account name to tasks provider", e)
|
||||
}
|
||||
|
||||
// synchronize again
|
||||
requestSync(Account(newName, oldAccount.type))
|
||||
}, null)
|
||||
activity.finish()
|
||||
})
|
||||
.setNegativeButton(android.R.string.cancel, { _, _ -> })
|
||||
.create()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* USER ACTIONS */
|
||||
|
||||
private fun deleteAccount() {
|
||||
val accountManager = AccountManager.get(this)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 22)
|
||||
accountManager.removeAccount(account, this, { future ->
|
||||
try {
|
||||
if (future.result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT))
|
||||
finish()
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't remove account", e)
|
||||
}
|
||||
}, null)
|
||||
else
|
||||
accountManager.removeAccount(account, { future ->
|
||||
try {
|
||||
if (future.result)
|
||||
finish()
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't remove account", e)
|
||||
}
|
||||
}, null)
|
||||
}
|
||||
|
||||
private fun requestSync() {
|
||||
requestSync(account)
|
||||
Snackbar.make(findViewById(R.id.parent), R.string.account_synchronizing_now, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -11,9 +11,12 @@ package at.bitfire.davdroid.ui
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.accounts.OnAccountsUpdateListener
|
||||
import android.app.Application
|
||||
import android.app.ListFragment
|
||||
import android.app.LoaderManager
|
||||
import android.content.AsyncTaskLoader
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Loader
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
@@ -21,36 +24,25 @@ import android.view.ViewGroup
|
||||
import android.widget.AbsListView
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.fragment.app.ListFragment
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import at.bitfire.davdroid.AccountsChangedReceiver
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.account.AccountActivity
|
||||
import kotlinx.android.synthetic.main.account_list_item.view.*
|
||||
|
||||
class AccountListFragment: ListFragment() {
|
||||
class AccountListFragment: ListFragment(), LoaderManager.LoaderCallbacks<Array<Account>> {
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
listAdapter = AccountListAdapter(requireActivity())
|
||||
|
||||
val model = ViewModelProviders.of(this).get(Model::class.java)
|
||||
model.accounts.observe(this, Observer { accounts ->
|
||||
val adapter = listAdapter as AccountListAdapter
|
||||
adapter.clear()
|
||||
adapter.addAll(*accounts)
|
||||
})
|
||||
listAdapter = AccountListAdapter(activity)
|
||||
|
||||
return inflater.inflate(R.layout.account_list, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
loaderManager.initLoader(0, arguments, this)
|
||||
|
||||
listView.choiceMode = AbsListView.CHOICE_MODE_SINGLE
|
||||
listView.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ ->
|
||||
val account = listAdapter!!.getItem(position) as Account
|
||||
val account = listAdapter.getItem(position) as Account
|
||||
val intent = Intent(activity, AccountActivity::class.java)
|
||||
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
|
||||
startActivity(intent)
|
||||
@@ -58,45 +50,54 @@ class AccountListFragment: ListFragment() {
|
||||
}
|
||||
|
||||
|
||||
// loader
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) =
|
||||
AccountLoader(activity)
|
||||
|
||||
override fun onLoadFinished(loader: Loader<Array<Account>>, accounts: Array<Account>) {
|
||||
val adapter = listAdapter as AccountListAdapter
|
||||
adapter.clear()
|
||||
adapter.addAll(*accounts)
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<Array<Account>>) {
|
||||
(listAdapter as AccountListAdapter).clear()
|
||||
}
|
||||
|
||||
class AccountLoader(
|
||||
context: Context
|
||||
): AsyncTaskLoader<Array<Account>>(context), OnAccountsUpdateListener {
|
||||
|
||||
override fun onStartLoading() =
|
||||
AccountsChangedReceiver.registerListener(this, true)
|
||||
|
||||
override fun onStopLoading() =
|
||||
AccountsChangedReceiver.unregisterListener(this)
|
||||
|
||||
override fun onAccountsUpdated(accounts: Array<Account>?) =
|
||||
forceLoad()
|
||||
|
||||
override fun loadInBackground(): Array<Account> =
|
||||
AccountManager.get(context).getAccountsByType(context.getString(R.string.account_type))
|
||||
|
||||
}
|
||||
|
||||
|
||||
// list adapter
|
||||
|
||||
class AccountListAdapter(
|
||||
context: Context
|
||||
): ArrayAdapter<Account>(context, R.layout.account_list_item) {
|
||||
|
||||
override fun getView(position: Int, _v: View?, parent: ViewGroup): View {
|
||||
val account = getItem(position)!!
|
||||
override fun getView(position: Int, v: View?, parent: ViewGroup?): View {
|
||||
val account = getItem(position)
|
||||
|
||||
val v = _v ?: LayoutInflater.from(context).inflate(R.layout.account_list_item, parent, false)
|
||||
val v = v ?: LayoutInflater.from(context).inflate(R.layout.account_list_item, parent, false)
|
||||
v.account_name.text = account.name
|
||||
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Model(
|
||||
application: Application
|
||||
): AndroidViewModel(application), OnAccountsUpdateListener {
|
||||
|
||||
val accounts = MutableLiveData<Array<out Account>>()
|
||||
|
||||
private val accountManager = AccountManager.get(getApplication())!!
|
||||
init {
|
||||
accountManager.addOnAccountsUpdatedListener(this, null, true)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
accountManager.removeOnAccountsUpdatedListener(this)
|
||||
}
|
||||
|
||||
override fun onAccountsUpdated(newAccounts: Array<out Account>) {
|
||||
val context = getApplication<Application>()
|
||||
accounts.postValue(
|
||||
AccountManager.get(context).getAccountsByType(context.getString(R.string.account_type))
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -8,34 +8,25 @@
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SyncStatusObserver
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.CalendarContract
|
||||
import android.security.KeyChain
|
||||
import android.support.v4.app.DialogFragment
|
||||
import android.support.v4.app.LoaderManager
|
||||
import android.support.v4.app.NavUtils
|
||||
import android.support.v4.content.AsyncTaskLoader
|
||||
import android.support.v4.content.Loader
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.support.v7.preference.*
|
||||
import android.support.v7.preference.Preference.OnPreferenceChangeListener
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.NavUtils
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.preference.*
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.*
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.closeCompat
|
||||
import at.bitfire.davdroid.model.Credentials
|
||||
import at.bitfire.davdroid.resource.LocalCalendar
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.ui.account.AccountActivity
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
@@ -43,7 +34,7 @@ import org.apache.commons.lang3.StringUtils
|
||||
class AccountSettingsActivity: AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_ACCOUNT = "account"
|
||||
val EXTRA_ACCOUNT = "account"
|
||||
}
|
||||
|
||||
private lateinit var account: Account
|
||||
@@ -73,331 +64,200 @@ class AccountSettingsActivity: AppCompatActivity() {
|
||||
false
|
||||
|
||||
|
||||
class AccountSettingsFragment: PreferenceFragmentCompat(), SyncStatusObserver, Settings.OnChangeListener {
|
||||
private lateinit var settings: Settings
|
||||
|
||||
class AccountSettingsFragment: PreferenceFragmentCompat(), LoaderManager.LoaderCallbacks<AccountSettings> {
|
||||
lateinit var account: Account
|
||||
private var statusChangeListener: Any? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
settings = Settings.getInstance(requireActivity())
|
||||
account = arguments!!.getParcelable(EXTRA_ACCOUNT)!!
|
||||
|
||||
account = arguments.getParcelable(EXTRA_ACCOUNT)
|
||||
loaderManager.initLoader(0, arguments, this)
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
override fun onCreatePreferences(bundle: Bundle?, s: String?) {
|
||||
addPreferencesFromResource(R.xml.settings_account)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
override fun onCreateLoader(id: Int, args: Bundle) =
|
||||
AccountSettingsLoader(context, args.getParcelable(EXTRA_ACCOUNT))
|
||||
|
||||
statusChangeListener = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this)
|
||||
settings.addOnChangeListener(this)
|
||||
|
||||
reload()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
statusChangeListener?.let {
|
||||
ContentResolver.removeStatusChangeListener(it)
|
||||
statusChangeListener = null
|
||||
}
|
||||
settings.removeOnChangeListener(this)
|
||||
}
|
||||
|
||||
override fun onStatusChanged(which: Int) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSettingsChanged() = reload()
|
||||
|
||||
private fun reload() {
|
||||
val accountSettings = AccountSettings(requireActivity(), account)
|
||||
|
||||
// preference group: authentication
|
||||
val prefUserName = findPreference<EditTextPreference>("username")!!
|
||||
val prefPassword = findPreference<EditTextPreference>("password")!!
|
||||
val prefCertAlias = findPreference<Preference>("certificate_alias")!!
|
||||
|
||||
val credentials = accountSettings.credentials()
|
||||
when (credentials.type) {
|
||||
Credentials.Type.UsernamePassword -> {
|
||||
prefUserName.isVisible = true
|
||||
prefUserName.summary = credentials.userName
|
||||
prefUserName.text = credentials.userName
|
||||
prefUserName.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
accountSettings.credentials(Credentials(newValue as String, credentials.password))
|
||||
reload()
|
||||
false
|
||||
}
|
||||
|
||||
prefPassword.isVisible = true
|
||||
prefPassword.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
accountSettings.credentials(Credentials(credentials.userName, newValue as String))
|
||||
reload()
|
||||
false
|
||||
}
|
||||
|
||||
prefCertAlias.isVisible = false
|
||||
}
|
||||
Credentials.Type.ClientCertificate -> {
|
||||
prefUserName.isVisible = false
|
||||
prefPassword.isVisible = false
|
||||
|
||||
prefCertAlias.isVisible = true
|
||||
prefCertAlias.summary = credentials.certificateAlias
|
||||
prefCertAlias.setOnPreferenceClickListener {
|
||||
KeyChain.choosePrivateKeyAlias(requireActivity(), { alias ->
|
||||
accountSettings.credentials(Credentials(certificateAlias = alias))
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
reload()
|
||||
}
|
||||
}, null, null, null, -1, credentials.certificateAlias)
|
||||
true
|
||||
}
|
||||
}
|
||||
override fun onLoadFinished(loader: Loader<AccountSettings>, settings: AccountSettings?) {
|
||||
if (settings == null) {
|
||||
activity.finish()
|
||||
return
|
||||
}
|
||||
|
||||
// preference group: sync
|
||||
// those are null if the respective sync type is not available for this account:
|
||||
val syncIntervalContacts = accountSettings.getSyncInterval(getString(R.string.address_books_authority))
|
||||
val syncIntervalCalendars = accountSettings.getSyncInterval(CalendarContract.AUTHORITY)
|
||||
val syncIntervalTasks = accountSettings.getSyncInterval(TaskProvider.ProviderName.OpenTasks.authority)
|
||||
|
||||
findPreference<ListPreference>("sync_interval_contacts")!!.let {
|
||||
if (syncIntervalContacts != null) {
|
||||
it.isEnabled = true
|
||||
it.isVisible = true
|
||||
it.value = syncIntervalContacts.toString()
|
||||
if (syncIntervalContacts == AccountSettings.SYNC_INTERVAL_MANUALLY)
|
||||
it.setSummary(R.string.settings_sync_summary_manually)
|
||||
else
|
||||
it.summary = getString(R.string.settings_sync_summary_periodically, syncIntervalContacts / 60)
|
||||
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { pref, newValue ->
|
||||
Handler(Looper.myLooper()).post {
|
||||
pref.isEnabled = false
|
||||
accountSettings.setSyncInterval(getString(R.string.address_books_authority), (newValue as String).toLong())
|
||||
reload()
|
||||
}
|
||||
false
|
||||
}
|
||||
} else
|
||||
it.isVisible = false
|
||||
}
|
||||
|
||||
findPreference<ListPreference>("sync_interval_calendars")!!.let {
|
||||
if (syncIntervalCalendars != null) {
|
||||
it.isEnabled = true
|
||||
it.isVisible = true
|
||||
it.value = syncIntervalCalendars.toString()
|
||||
if (syncIntervalCalendars == AccountSettings.SYNC_INTERVAL_MANUALLY)
|
||||
it.setSummary(R.string.settings_sync_summary_manually)
|
||||
else
|
||||
it.summary = getString(R.string.settings_sync_summary_periodically, syncIntervalCalendars / 60)
|
||||
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { pref, newValue ->
|
||||
Handler(Looper.myLooper()).post {
|
||||
pref.isEnabled = false
|
||||
accountSettings.setSyncInterval(CalendarContract.AUTHORITY, (newValue as String).toLong())
|
||||
reload()
|
||||
}
|
||||
false
|
||||
}
|
||||
} else
|
||||
it.isVisible = false
|
||||
}
|
||||
|
||||
findPreference<ListPreference>("sync_interval_tasks")!!.let {
|
||||
if (syncIntervalTasks != null) {
|
||||
it.isEnabled = true
|
||||
it.isVisible = true
|
||||
it.value = syncIntervalTasks.toString()
|
||||
if (syncIntervalTasks == AccountSettings.SYNC_INTERVAL_MANUALLY)
|
||||
it.setSummary(R.string.settings_sync_summary_manually)
|
||||
else
|
||||
it.summary = getString(R.string.settings_sync_summary_periodically, syncIntervalTasks / 60)
|
||||
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { pref, newValue ->
|
||||
Handler(Looper.myLooper()).post {
|
||||
pref.isEnabled = false
|
||||
accountSettings.setSyncInterval(TaskProvider.ProviderName.OpenTasks.authority, (newValue as String).toLong())
|
||||
reload()
|
||||
}
|
||||
false
|
||||
}
|
||||
} else
|
||||
it.isVisible = false
|
||||
}
|
||||
|
||||
val prefWifiOnly = findPreference<SwitchPreferenceCompat>("sync_wifi_only")!!
|
||||
prefWifiOnly.isEnabled = !settings.has(AccountSettings.KEY_WIFI_ONLY)
|
||||
prefWifiOnly.isChecked = accountSettings.getSyncWifiOnly()
|
||||
prefWifiOnly.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, wifiOnly ->
|
||||
accountSettings.setSyncWiFiOnly(wifiOnly as Boolean)
|
||||
reload()
|
||||
// category: authentication
|
||||
val prefUserName = findPreference("username") as EditTextPreference
|
||||
prefUserName.summary = settings.username()
|
||||
prefUserName.text = settings.username()
|
||||
prefUserName.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
|
||||
settings.username(newValue as String)
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
|
||||
val prefWifiOnlySSIDs = findPreference<EditTextPreference>("sync_wifi_only_ssids")!!
|
||||
val onlySSIDs = accountSettings.getSyncWifiOnlySSIDs()?.joinToString(", ")
|
||||
val prefPassword = findPreference("password") as EditTextPreference
|
||||
prefPassword.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
|
||||
settings.password(newValue as String)
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
|
||||
// category: synchronization
|
||||
val prefSyncContacts = findPreference("sync_interval_contacts") as ListPreference
|
||||
val syncIntervalContacts = settings.getSyncInterval(App.addressBooksAuthority)
|
||||
if (syncIntervalContacts != null) {
|
||||
prefSyncContacts.value = syncIntervalContacts.toString()
|
||||
if (syncIntervalContacts == AccountSettings.SYNC_INTERVAL_MANUALLY)
|
||||
prefSyncContacts.setSummary(R.string.settings_sync_summary_manually)
|
||||
else
|
||||
prefSyncContacts.summary = getString(R.string.settings_sync_summary_periodically, syncIntervalContacts / 60)
|
||||
prefSyncContacts.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
|
||||
settings.setSyncInterval(App.addressBooksAuthority, (newValue as String).toLong())
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
} else
|
||||
prefSyncContacts.isVisible = false
|
||||
|
||||
val prefSyncCalendars = findPreference("sync_interval_calendars") as ListPreference
|
||||
val syncIntervalCalendars = settings.getSyncInterval(CalendarContract.AUTHORITY)
|
||||
if (syncIntervalCalendars != null) {
|
||||
prefSyncCalendars.value = syncIntervalCalendars.toString()
|
||||
if (syncIntervalCalendars == AccountSettings.SYNC_INTERVAL_MANUALLY)
|
||||
prefSyncCalendars.setSummary(R.string.settings_sync_summary_manually)
|
||||
else
|
||||
prefSyncCalendars.summary = getString(R.string.settings_sync_summary_periodically, syncIntervalCalendars / 60)
|
||||
prefSyncCalendars.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
settings.setSyncInterval(CalendarContract.AUTHORITY, (newValue as String).toLong())
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
} else
|
||||
prefSyncCalendars.isVisible = false
|
||||
|
||||
val prefSyncTasks = findPreference("sync_interval_tasks") as ListPreference
|
||||
val syncIntervalTasks = settings.getSyncInterval(TaskProvider.ProviderName.OpenTasks.authority)
|
||||
if (syncIntervalTasks != null) {
|
||||
prefSyncTasks.value = syncIntervalTasks.toString()
|
||||
if (syncIntervalTasks == AccountSettings.SYNC_INTERVAL_MANUALLY)
|
||||
prefSyncTasks.setSummary(R.string.settings_sync_summary_manually)
|
||||
else
|
||||
prefSyncTasks.summary = getString(R.string.settings_sync_summary_periodically, syncIntervalTasks / 60)
|
||||
prefSyncTasks.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
settings.setSyncInterval(TaskProvider.ProviderName.OpenTasks.authority, (newValue as String).toLong())
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
} else
|
||||
prefSyncTasks.isVisible = false
|
||||
|
||||
val prefWifiOnly = findPreference("sync_wifi_only") as SwitchPreferenceCompat
|
||||
prefWifiOnly.isChecked = settings.getSyncWifiOnly()
|
||||
prefWifiOnly.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, wifiOnly ->
|
||||
settings.setSyncWiFiOnly(wifiOnly as Boolean)
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
|
||||
val prefWifiOnlySSIDs = findPreference("sync_wifi_only_ssids") as EditTextPreference
|
||||
val onlySSIDs = settings.getSyncWifiOnlySSIDs()?.joinToString(", ")
|
||||
prefWifiOnlySSIDs.text = onlySSIDs
|
||||
if (onlySSIDs != null)
|
||||
prefWifiOnlySSIDs.summary = getString(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1)
|
||||
R.string.settings_sync_wifi_only_ssids_on_location_services else R.string.settings_sync_wifi_only_ssids_on, onlySSIDs)
|
||||
prefWifiOnlySSIDs.summary = getString(R.string.settings_sync_wifi_only_ssids_on, onlySSIDs)
|
||||
else
|
||||
prefWifiOnlySSIDs.setSummary(R.string.settings_sync_wifi_only_ssids_off)
|
||||
prefWifiOnlySSIDs.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
accountSettings.setSyncWifiOnlySSIDs((newValue as String).split(',').mapNotNull { StringUtils.trimToNull(it) }.distinct())
|
||||
reload()
|
||||
settings.setSyncWifiOnlySSIDs((newValue as String).split(',').map { StringUtils.trimToNull(it) }.filterNotNull().distinct())
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
|
||||
// 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 &&
|
||||
accountSettings.getSyncWifiOnly() && onlySSIDs != null &&
|
||||
ContextCompat.checkSelfPermission(requireActivity(), Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED)
|
||||
requestPermissions(arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION), 0)
|
||||
// category: CardDAV
|
||||
val prefGroupMethod = findPreference("contact_group_method") as ListPreference
|
||||
if (syncIntervalContacts != null) {
|
||||
prefGroupMethod.value = settings.getGroupMethod().name
|
||||
prefGroupMethod.summary = prefGroupMethod.entry
|
||||
prefGroupMethod.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, groupMethod ->
|
||||
settings.setGroupMethod(GroupMethod.valueOf(groupMethod as String))
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
} else
|
||||
prefGroupMethod.isEnabled = false
|
||||
|
||||
// preference group: CardDAV
|
||||
findPreference<ListPreference>("contact_group_method")!!.let {
|
||||
if (syncIntervalContacts != null) {
|
||||
it.isVisible = true
|
||||
it.value = accountSettings.getGroupMethod().name
|
||||
it.summary = it.entry
|
||||
if (settings.has(AccountSettings.KEY_CONTACT_GROUP_METHOD))
|
||||
it.isEnabled = false
|
||||
else {
|
||||
it.isEnabled = true
|
||||
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, groupMethod ->
|
||||
AlertDialog.Builder(requireActivity())
|
||||
.setIcon(R.drawable.ic_error_dark)
|
||||
.setTitle(R.string.settings_contact_group_method_change)
|
||||
.setMessage(R.string.settings_contact_group_method_change_reload_contacts)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
// change group method
|
||||
accountSettings.setGroupMethod(GroupMethod.valueOf(groupMethod as String))
|
||||
reload()
|
||||
|
||||
// reload all contacts
|
||||
val args = Bundle(1)
|
||||
args.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true)
|
||||
ContentResolver.requestSync(account, getString(R.string.address_books_authority), args)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
false
|
||||
}
|
||||
// category: CalDAV
|
||||
val prefTimeRangePastDays = findPreference("time_range_past_days") as EditTextPreference
|
||||
if (syncIntervalCalendars != null) {
|
||||
val pastDays = settings.getTimeRangePastDays()
|
||||
if (pastDays != null) {
|
||||
prefTimeRangePastDays.text = pastDays.toString()
|
||||
prefTimeRangePastDays.summary = resources.getQuantityString(R.plurals.settings_sync_time_range_past_days, pastDays, pastDays)
|
||||
} else {
|
||||
prefTimeRangePastDays.text = null
|
||||
prefTimeRangePastDays.setSummary(R.string.settings_sync_time_range_past_none)
|
||||
}
|
||||
prefTimeRangePastDays.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
var days: Int
|
||||
try {
|
||||
days = (newValue as String).toInt()
|
||||
} catch(ignored: NumberFormatException) {
|
||||
days = -1
|
||||
}
|
||||
} else
|
||||
it.isVisible = false
|
||||
}
|
||||
settings.setTimeRangePastDays(if (days < 0) null else days)
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
} else
|
||||
prefTimeRangePastDays.isEnabled = false
|
||||
|
||||
// preference group: CalDAV
|
||||
findPreference<EditTextPreference>("time_range_past_days")!!.let {
|
||||
if (syncIntervalCalendars != null) {
|
||||
it.isVisible = true
|
||||
val pastDays = accountSettings.getTimeRangePastDays()
|
||||
if (pastDays != null) {
|
||||
it.text = pastDays.toString()
|
||||
it.summary = resources.getQuantityString(R.plurals.settings_sync_time_range_past_days, pastDays, pastDays)
|
||||
} else {
|
||||
it.text = null
|
||||
it.setSummary(R.string.settings_sync_time_range_past_none)
|
||||
}
|
||||
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
val days = try {
|
||||
(newValue as String).toInt()
|
||||
} catch(e: NumberFormatException) {
|
||||
-1
|
||||
}
|
||||
accountSettings.setTimeRangePastDays(if (days < 0) null else days)
|
||||
val prefManageColors = findPreference("manage_calendar_colors") as SwitchPreferenceCompat
|
||||
if (syncIntervalCalendars != null || syncIntervalTasks != null) {
|
||||
prefManageColors.isChecked = settings.getManageCalendarColors()
|
||||
prefManageColors.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
settings.setManageCalendarColors(newValue as Boolean)
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
} else
|
||||
prefManageColors.isEnabled = false
|
||||
|
||||
// reset sync state of all calendars in this account to trigger a full sync
|
||||
if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED) {
|
||||
requireContext().contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider ->
|
||||
try {
|
||||
AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null).forEach { calendar ->
|
||||
calendar.lastSyncState = null
|
||||
}
|
||||
} finally {
|
||||
provider.closeCompat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reload()
|
||||
false
|
||||
}
|
||||
} else
|
||||
it.isVisible = false
|
||||
}
|
||||
|
||||
findPreference<SwitchPreferenceCompat>("manage_calendar_colors")!!.let {
|
||||
if (syncIntervalCalendars != null || syncIntervalTasks != null) {
|
||||
it.isVisible = true
|
||||
it.isEnabled = !settings.has(AccountSettings.KEY_MANAGE_CALENDAR_COLORS)
|
||||
it.isChecked = accountSettings.getManageCalendarColors()
|
||||
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
accountSettings.setManageCalendarColors(newValue as Boolean)
|
||||
reload()
|
||||
false
|
||||
}
|
||||
} else
|
||||
it.isVisible = false
|
||||
}
|
||||
|
||||
findPreference<SwitchPreferenceCompat>("event_colors")!!.let {
|
||||
if (syncIntervalCalendars != null) {
|
||||
it.isVisible = true
|
||||
it.isEnabled = !settings.has(AccountSettings.KEY_EVENT_COLORS)
|
||||
it.isChecked = accountSettings.getEventColors()
|
||||
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
if (newValue as Boolean) {
|
||||
accountSettings.setEventColors(true)
|
||||
reload()
|
||||
} else
|
||||
AlertDialog.Builder(requireActivity())
|
||||
.setIcon(R.drawable.ic_error_dark)
|
||||
.setTitle(R.string.settings_event_colors)
|
||||
.setMessage(R.string.settings_event_colors_off_confirm)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
accountSettings.setEventColors(false)
|
||||
reload()
|
||||
}
|
||||
.show()
|
||||
false
|
||||
}
|
||||
} else
|
||||
it.isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
override fun onLoaderReset(loader: Loader<AccountSettings>) {
|
||||
}
|
||||
|
||||
if (permissions.first() == Manifest.permission.ACCESS_COARSE_LOCATION && grantResults.first() == PackageManager.PERMISSION_DENIED) {
|
||||
// location permission denied, reset SSID restriction
|
||||
AccountSettings(requireActivity(), account).setSyncWifiOnlySSIDs(null)
|
||||
reload()
|
||||
}
|
||||
|
||||
AlertDialog.Builder(requireActivity())
|
||||
.setIcon(R.drawable.ic_network_wifi_dark)
|
||||
.setTitle(R.string.settings_sync_wifi_only_ssids)
|
||||
.setMessage(R.string.settings_sync_wifi_only_ssids_location_permission)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ -> }
|
||||
.setNeutralButton(R.string.settings_more_info_faq) { _, _ ->
|
||||
val faqUrl = App.homepageUrl(requireActivity()).buildUpon()
|
||||
.appendPath("faq").appendPath("wifi-ssid-restriction-location-permission")
|
||||
.build()
|
||||
val intent = Intent(Intent.ACTION_VIEW, faqUrl)
|
||||
startActivity(Intent.createChooser(intent, null))
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
class AccountSettingsLoader(
|
||||
context: Context,
|
||||
val account: Account
|
||||
): AsyncTaskLoader<AccountSettings>(context), SyncStatusObserver {
|
||||
|
||||
lateinit var listenerHandle: Any
|
||||
|
||||
override fun onStartLoading() {
|
||||
forceLoad()
|
||||
listenerHandle = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this)
|
||||
}
|
||||
|
||||
override fun onStopLoading() =
|
||||
ContentResolver.removeStatusChangeListener(listenerHandle)
|
||||
|
||||
override fun abandon() = onStopLoading()
|
||||
|
||||
override fun loadInBackground() =
|
||||
try {
|
||||
AccountSettings(context, account)
|
||||
} catch(e: InvalidAccountException) {
|
||||
null
|
||||
}
|
||||
|
||||
override fun onStatusChanged(which: Int) {
|
||||
Logger.log.fine("Reloading account settings")
|
||||
forceLoad()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -12,60 +12,52 @@ import android.accounts.AccountManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.Intent
|
||||
import android.content.SyncStatusObserver
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.design.widget.NavigationView
|
||||
import android.support.design.widget.Snackbar
|
||||
import android.support.v4.view.GravityCompat
|
||||
import android.support.v7.app.ActionBarDrawerToggle
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.ActionBarDrawerToggle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.GravityCompat
|
||||
import android.view.View
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.ui.setup.LoginActivity
|
||||
import com.google.android.material.navigation.NavigationView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.android.synthetic.main.accounts_content.*
|
||||
import kotlinx.android.synthetic.main.activity_accounts.*
|
||||
import kotlinx.android.synthetic.main.activity_accounts.view.*
|
||||
import java.util.*
|
||||
|
||||
class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener, SyncStatusObserver {
|
||||
|
||||
companion object {
|
||||
val accountsDrawerHandler = DefaultAccountsDrawerHandler()
|
||||
|
||||
const val fragTagStartup = "startup"
|
||||
}
|
||||
|
||||
private lateinit var settings: Settings
|
||||
|
||||
private var syncStatusSnackbar: Snackbar? = null
|
||||
private var syncStatusObserver: Any? = null
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
settings = Settings.getInstance(this)
|
||||
|
||||
setContentView(R.layout.activity_accounts)
|
||||
|
||||
setSupportActionBar(toolbar)
|
||||
|
||||
if (supportFragmentManager.findFragmentByTag(fragTagStartup) == null) {
|
||||
val ft = supportFragmentManager.beginTransaction()
|
||||
StartupDialogFragment.getStartupDialogs(this).forEach { ft.add(it, fragTagStartup) }
|
||||
ft.commit()
|
||||
}
|
||||
fab.setOnClickListener({
|
||||
startActivity(Intent(this@AccountsActivity, LoginActivity::class.java))
|
||||
})
|
||||
|
||||
fab.setOnClickListener {
|
||||
startActivity(Intent(this, LoginActivity::class.java))
|
||||
}
|
||||
fab.show()
|
||||
|
||||
accountsDrawerHandler.initMenu(this, drawer_layout.nav_view.menu)
|
||||
val toggle = ActionBarDrawerToggle(
|
||||
this, drawer_layout, toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close)
|
||||
drawer_layout.addDrawerListener(toggle)
|
||||
drawer_layout.setDrawerListener(toggle)
|
||||
toggle.syncState()
|
||||
|
||||
nav_view.setNavigationItemSelectedListener(this)
|
||||
nav_view.itemIconTintList = null
|
||||
|
||||
if (savedInstanceState == null && packageName != getCallingPackage()) {
|
||||
// first call
|
||||
val ft = supportFragmentManager.beginTransaction()
|
||||
StartupDialogFragment.getStartupDialogs(this).forEach { ft.add(it, null) }
|
||||
ft.commit()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@@ -77,7 +69,6 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
syncStatusObserver?.let {
|
||||
ContentResolver.removeStatusChangeListener(it)
|
||||
syncStatusObserver = null
|
||||
@@ -93,9 +84,9 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
|
||||
if (!ContentResolver.getMasterSyncAutomatically()) {
|
||||
val snackbar = Snackbar
|
||||
.make(findViewById(R.id.coordinator), R.string.accounts_global_sync_disabled, Snackbar.LENGTH_INDEFINITE)
|
||||
.setAction(R.string.accounts_global_sync_enable) {
|
||||
ContentResolver.setMasterSyncAutomatically(true)
|
||||
}
|
||||
.setAction(R.string.accounts_global_sync_enable, {
|
||||
ContentResolver.setMasterSyncAutomatically(true)
|
||||
})
|
||||
syncStatusSnackbar = snackbar
|
||||
snackbar.show()
|
||||
}
|
||||
@@ -110,7 +101,28 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
|
||||
}
|
||||
|
||||
override fun onNavigationItemSelected(item: MenuItem): Boolean {
|
||||
val processed = accountsDrawerHandler.onNavigationItemSelected(this, item)
|
||||
var processed = true
|
||||
when (item.itemId) {
|
||||
R.id.nav_about ->
|
||||
startActivity(Intent(this, AboutActivity::class.java))
|
||||
R.id.nav_app_settings ->
|
||||
startActivity(Intent(this, AppSettingsActivity::class.java))
|
||||
R.id.nav_twitter ->
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://twitter.com/davdroidapp")))
|
||||
R.id.nav_website ->
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.homepage_url))))
|
||||
R.id.nav_faq ->
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.navigation_drawer_faq_url))))
|
||||
R.id.nav_forums ->
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.homepage_url))
|
||||
.buildUpon().appendEncodedPath("forums/").build()))
|
||||
R.id.nav_donate ->
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.homepage_url))
|
||||
.buildUpon().appendEncodedPath("donate/").build()))
|
||||
else ->
|
||||
processed = false
|
||||
}
|
||||
|
||||
drawer_layout.closeDrawer(GravityCompat.START)
|
||||
return processed
|
||||
}
|
||||
|
||||
@@ -9,25 +9,26 @@
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import at.bitfire.cert4android.CustomCertManager
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import android.support.design.widget.Snackbar
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.support.v7.preference.EditTextPreference
|
||||
import android.support.v7.preference.Preference
|
||||
import android.support.v7.preference.PreferenceFragmentCompat
|
||||
import android.support.v7.preference.SwitchPreferenceCompat
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.CustomCertificates
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.Settings
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
|
||||
class AppSettingsActivity: AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_SCROLL_TO = "scrollTo"
|
||||
val EXTRA_SCROLL_TO = "scrollTo"
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -43,120 +44,132 @@ class AppSettingsActivity: AppCompatActivity() {
|
||||
}
|
||||
|
||||
|
||||
class SettingsFragment: PreferenceFragmentCompat(), Settings.OnChangeListener {
|
||||
class SettingsFragment: PreferenceFragmentCompat() {
|
||||
lateinit var dbHelper: ServiceDB.OpenHelper
|
||||
lateinit var settings: Settings
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
dbHelper = ServiceDB.OpenHelper(context)
|
||||
settings = Settings(dbHelper.readableDatabase)
|
||||
|
||||
// will call onCreatePreferences, so settings should be already initialized
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
dbHelper.close()
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(bundle: Bundle?, s: String?) {
|
||||
addPreferencesFromResource(R.xml.settings_app)
|
||||
loadSettings()
|
||||
|
||||
// UI settings
|
||||
findPreference<Preference>("notification_settings")!!.apply {
|
||||
if (Build.VERSION.SDK_INT >= 26)
|
||||
onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
startActivity(Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID)
|
||||
})
|
||||
false
|
||||
}
|
||||
else
|
||||
isVisible = false
|
||||
}
|
||||
findPreference<Preference>("reset_hints")!!.onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
val prefResetHints = findPreference("reset_hints")
|
||||
prefResetHints.onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
resetHints()
|
||||
false
|
||||
true
|
||||
}
|
||||
|
||||
// security settings
|
||||
findPreference<SwitchPreferenceCompat>(Settings.DISTRUST_SYSTEM_CERTIFICATES)!!.apply {
|
||||
isVisible = BuildConfig.customCerts
|
||||
isEnabled = true
|
||||
val prefOverrideProxy = findPreference("override_proxy") as SwitchPreferenceCompat
|
||||
prefOverrideProxy.isChecked = settings.getBoolean(App.OVERRIDE_PROXY, false)
|
||||
prefOverrideProxy.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
settings.putBoolean(App.OVERRIDE_PROXY, newValue as Boolean)
|
||||
true
|
||||
}
|
||||
findPreference<Preference>("reset_certificates")!!.apply {
|
||||
isVisible = BuildConfig.customCerts
|
||||
isEnabled = true
|
||||
onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
resetCertificates()
|
||||
|
||||
val prefProxyHost = findPreference("proxy_host") as EditTextPreference
|
||||
val proxyHost = settings.getString(App.OVERRIDE_PROXY_HOST, App.OVERRIDE_PROXY_HOST_DEFAULT)
|
||||
prefProxyHost.text = proxyHost
|
||||
prefProxyHost.summary = proxyHost
|
||||
prefProxyHost.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
val host = newValue as String
|
||||
try {
|
||||
URI(null, host, null, null)
|
||||
settings.putString(App.OVERRIDE_PROXY_HOST, host)
|
||||
prefProxyHost.summary = host
|
||||
true
|
||||
} catch(e: URISyntaxException) {
|
||||
Snackbar.make(view!!, e.localizedMessage, Snackbar.LENGTH_LONG).show()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
arguments?.getString(EXTRA_SCROLL_TO)?.let { key ->
|
||||
scrollToPreference(key)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadSettings() {
|
||||
val settings = Settings.getInstance(requireActivity())
|
||||
|
||||
// connection settings
|
||||
findPreference<SwitchPreferenceCompat>(Settings.OVERRIDE_PROXY)!!.apply {
|
||||
isChecked = settings.getBoolean(Settings.OVERRIDE_PROXY) ?: Settings.OVERRIDE_PROXY_DEFAULT
|
||||
isEnabled = settings.isWritable(Settings.OVERRIDE_PROXY)
|
||||
}
|
||||
|
||||
findPreference<EditTextPreference>(Settings.OVERRIDE_PROXY_HOST)!!.apply {
|
||||
isEnabled = settings.isWritable(Settings.OVERRIDE_PROXY_HOST)
|
||||
val proxyHost = settings.getString(Settings.OVERRIDE_PROXY_HOST) ?: Settings.OVERRIDE_PROXY_HOST_DEFAULT
|
||||
text = proxyHost
|
||||
summary = proxyHost
|
||||
onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
val host = newValue as String
|
||||
try {
|
||||
URI(null, host, null, null)
|
||||
settings.putString(Settings.OVERRIDE_PROXY_HOST, host)
|
||||
summary = host
|
||||
true
|
||||
} catch(e: URISyntaxException) {
|
||||
Snackbar.make(view!!, e.localizedMessage, Snackbar.LENGTH_LONG).show()
|
||||
false
|
||||
}
|
||||
val prefProxyPort = findPreference("proxy_port") as EditTextPreference
|
||||
val proxyPort = settings.getString(App.OVERRIDE_PROXY_PORT, App.OVERRIDE_PROXY_PORT_DEFAULT.toString())
|
||||
prefProxyPort.text = proxyPort
|
||||
prefProxyPort.summary = proxyPort
|
||||
prefProxyPort.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
var port: Int
|
||||
try {
|
||||
port = Integer.parseInt(newValue as String)
|
||||
} catch(e: NumberFormatException) {
|
||||
port = App.OVERRIDE_PROXY_PORT_DEFAULT
|
||||
}
|
||||
settings.putInt(App.OVERRIDE_PROXY_PORT, port)
|
||||
prefProxyPort.text = port.toString()
|
||||
prefProxyPort.summary = port.toString()
|
||||
true
|
||||
}
|
||||
|
||||
findPreference<EditTextPreference>(Settings.OVERRIDE_PROXY_PORT)!!.apply {
|
||||
isEnabled = settings.isWritable(Settings.OVERRIDE_PROXY_PORT)
|
||||
val proxyPort = settings.getInt(Settings.OVERRIDE_PROXY_PORT) ?: Settings.OVERRIDE_PROXY_PORT_DEFAULT
|
||||
text = proxyPort.toString()
|
||||
summary = proxyPort.toString()
|
||||
onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
try {
|
||||
val port = Integer.parseInt(newValue as String)
|
||||
if (port in 1..65535) {
|
||||
settings.putInt(Settings.OVERRIDE_PROXY_PORT, port)
|
||||
text = port.toString()
|
||||
summary = port.toString()
|
||||
true
|
||||
} else
|
||||
false
|
||||
} catch(e: NumberFormatException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
val prefDistrustSystemCerts = findPreference("distrust_system_certs") as SwitchPreferenceCompat
|
||||
if (CustomCertificates.certManager == null)
|
||||
prefDistrustSystemCerts.isVisible = false
|
||||
else
|
||||
prefDistrustSystemCerts.isChecked = settings.getBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, false)
|
||||
prefDistrustSystemCerts.onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
setDistrustSystemCerts(prefDistrustSystemCerts.isChecked)
|
||||
true
|
||||
}
|
||||
|
||||
// security settings
|
||||
findPreference<SwitchPreferenceCompat>(Settings.DISTRUST_SYSTEM_CERTIFICATES)!!
|
||||
.isChecked = settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES) ?: Settings.DISTRUST_SYSTEM_CERTIFICATES_DEFAULT
|
||||
}
|
||||
val prefResetCertificates = findPreference("reset_certificates")
|
||||
if (CustomCertificates.certManager == null)
|
||||
prefResetCertificates.isVisible = false
|
||||
prefResetCertificates.onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
resetCertificates()
|
||||
true
|
||||
}
|
||||
|
||||
override fun onSettingsChanged() {
|
||||
loadSettings()
|
||||
}
|
||||
val prefLogToExternalStorage = findPreference("log_to_external_storage") as SwitchPreferenceCompat
|
||||
prefLogToExternalStorage.isChecked = settings.getBoolean(App.LOG_TO_EXTERNAL_STORAGE, false)
|
||||
prefLogToExternalStorage.onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
setExternalLogging(prefLogToExternalStorage.isChecked)
|
||||
true
|
||||
}
|
||||
|
||||
arguments?.getString(EXTRA_SCROLL_TO)?.let { scrollToPreference(it) }
|
||||
}
|
||||
|
||||
private fun resetHints() {
|
||||
val settings = Settings.getInstance(requireActivity())
|
||||
settings.remove(StartupDialogFragment.HINT_AUTOSTART_PERMISSIONS)
|
||||
settings.remove(StartupDialogFragment.HINT_BATTERY_OPTIMIZATIONS)
|
||||
settings.remove(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED)
|
||||
settings.remove(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED)
|
||||
Snackbar.make(view!!, R.string.app_settings_reset_hints_success, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
private fun resetCertificates() {
|
||||
if (CustomCertManager.resetCertificates(activity!!))
|
||||
Snackbar.make(view!!, getString(R.string.app_settings_reset_certificates_success), Snackbar.LENGTH_LONG).show()
|
||||
private fun setDistrustSystemCerts(distrust: Boolean) {
|
||||
settings.putBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, distrust)
|
||||
|
||||
// re-initialize certificate manager
|
||||
CustomCertificates.reinitCertManager(context)
|
||||
|
||||
// reinitialize certificate manager of :sync process
|
||||
context.sendBroadcast(Intent(Settings.ReinitSettingsReceiver.ACTION_REINIT_SETTINGS))
|
||||
}
|
||||
|
||||
private fun resetCertificates() {
|
||||
CustomCertificates.certManager?.resetCertificates()
|
||||
Snackbar.make(view!!, getString(R.string.app_settings_reset_certificates_success), Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
private fun setExternalLogging(externalLogging: Boolean) {
|
||||
settings.putBoolean(App.LOG_TO_EXTERNAL_STORAGE, externalLogging)
|
||||
|
||||
// reinitialize logger of default process
|
||||
Logger.reinitLogger(context)
|
||||
|
||||
// reinitialize logger of :sync process
|
||||
context.sendBroadcast(Intent(Settings.ReinitSettingsReceiver.ACTION_REINIT_SETTINGS))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,48 +9,43 @@
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.Application
|
||||
import android.app.LoaderManager
|
||||
import android.content.AsyncTaskLoader
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Loader
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.NavUtils
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.NavUtils
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import android.widget.ArrayAdapter
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.databinding.ActivityCreateAddressBookBinding
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.model.Service
|
||||
import at.bitfire.davdroid.ui.account.AccountActivity
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import kotlinx.android.synthetic.main.activity_create_address_book.*
|
||||
import okhttp3.HttpUrl
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.util.*
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class CreateAddressBookActivity: AppCompatActivity() {
|
||||
class CreateAddressBookActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<CreateAddressBookActivity.AccountInfo> {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_ACCOUNT = "account"
|
||||
val EXTRA_ACCOUNT = "account"
|
||||
}
|
||||
|
||||
private lateinit var model: Model
|
||||
private lateinit var account: Account
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
account = intent.getParcelableExtra(EXTRA_ACCOUNT)
|
||||
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
setContentView(R.layout.activity_create_address_book)
|
||||
|
||||
model = ViewModelProviders.of(this).get(Model::class.java)
|
||||
(intent?.getParcelableExtra(EXTRA_ACCOUNT) as? Account)?.let {
|
||||
model.initialize(it)
|
||||
}
|
||||
|
||||
val binding = DataBindingUtil.setContentView<ActivityCreateAddressBookBinding>(this, R.layout.activity_create_address_book)
|
||||
binding.lifecycleOwner = this
|
||||
binding.model = model
|
||||
loaderManager.initLoader(0, intent.extras, this)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
@@ -61,81 +56,77 @@ class CreateAddressBookActivity: AppCompatActivity() {
|
||||
override fun onOptionsItemSelected(item: MenuItem) =
|
||||
if (item.itemId == android.R.id.home) {
|
||||
val intent = Intent(this, AccountActivity::class.java)
|
||||
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, model.account)
|
||||
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
|
||||
NavUtils.navigateUpTo(this, intent)
|
||||
true
|
||||
} else
|
||||
false
|
||||
|
||||
fun onCreateCollection(item: MenuItem) {
|
||||
val homeSet = home_sets.selectedItem as String
|
||||
|
||||
var ok = true
|
||||
HttpUrl.parse(homeSet)?.let {
|
||||
val info = CollectionInfo(it.resolve(UUID.randomUUID().toString() + "/").toString())
|
||||
info.displayName = display_name.text.toString()
|
||||
if (info.displayName.isNullOrBlank()) {
|
||||
display_name.error = getString(R.string.create_collection_display_name_required)
|
||||
ok = false
|
||||
}
|
||||
|
||||
val args = Bundle()
|
||||
args.putString(CreateCollectionFragment.ARG_SERVICE_TYPE, Service.TYPE_CARDDAV)
|
||||
info.description = StringUtils.trimToNull(description.text.toString())
|
||||
|
||||
val parent = model.homeSets.value?.getItem(model.idxHomeSet.value!!) ?: return
|
||||
args.putString(CreateCollectionFragment.ARG_URL, parent.url.resolve(UUID.randomUUID().toString() + "/").toString())
|
||||
|
||||
val displayName = model.displayName.value
|
||||
if (displayName.isNullOrBlank()) {
|
||||
model.displayNameError.value = getString(R.string.create_collection_display_name_required)
|
||||
ok = false
|
||||
} else {
|
||||
args.putString(CreateCollectionFragment.ARG_DISPLAY_NAME, displayName)
|
||||
model.displayNameError.value = null
|
||||
}
|
||||
|
||||
StringUtils.trimToNull(model.description.value)?.let {
|
||||
args.putString(CreateCollectionFragment.ARG_DESCRIPTION, it)
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
args.putParcelable(CreateCollectionFragment.ARG_ACCOUNT, model.account)
|
||||
args.putString(CreateCollectionFragment.ARG_TYPE, Collection.TYPE_ADDRESSBOOK)
|
||||
val frag = CreateCollectionFragment()
|
||||
frag.arguments = args
|
||||
frag.show(supportFragmentManager, null)
|
||||
if (ok) {
|
||||
info.type = CollectionInfo.Type.ADDRESS_BOOK
|
||||
CreateCollectionFragment.newInstance(account, info).show(supportFragmentManager, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Model(
|
||||
application: Application
|
||||
) : AndroidViewModel(application) {
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) = AccountInfoLoader(this, account)
|
||||
|
||||
var account: Account? = null
|
||||
override fun onLoadFinished(loader: Loader<AccountInfo>, info: AccountInfo?) {
|
||||
info?.let {
|
||||
home_sets.adapter = ArrayAdapter<String>(this, android.R.layout.simple_spinner_dropdown_item, it.homeSets)
|
||||
}
|
||||
}
|
||||
|
||||
val displayName = MutableLiveData<String>()
|
||||
val displayNameError = MutableLiveData<String>()
|
||||
override fun onLoaderReset(loader: Loader<AccountInfo>) {
|
||||
}
|
||||
|
||||
val description = MutableLiveData<String>()
|
||||
class AccountInfo {
|
||||
val homeSets = LinkedList<String>()
|
||||
}
|
||||
|
||||
val homeSets = MutableLiveData<HomeSetAdapter>()
|
||||
val idxHomeSet = MutableLiveData<Int>()
|
||||
class AccountInfoLoader(
|
||||
context: Context,
|
||||
val account: Account
|
||||
): AsyncTaskLoader<AccountInfo>(context) {
|
||||
|
||||
@MainThread
|
||||
fun initialize(account: Account) {
|
||||
if (this.account != null)
|
||||
return
|
||||
this.account = account
|
||||
override fun onStartLoading() = forceLoad()
|
||||
|
||||
thread {
|
||||
// load account info
|
||||
val adapter = HomeSetAdapter(getApplication())
|
||||
override fun loadInBackground(): AccountInfo? {
|
||||
val info = AccountInfo()
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
// find DAV service and home sets
|
||||
val db = dbHelper.readableDatabase
|
||||
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 { cursor ->
|
||||
if (!cursor.moveToNext())
|
||||
return null
|
||||
val strServiceID = cursor.getString(0)
|
||||
|
||||
val db = AppDatabase.getInstance(getApplication())
|
||||
db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV)?.let { service ->
|
||||
val homeSets = db.homeSetDao().getBindableByService(service.id)
|
||||
adapter.addAll(homeSets)
|
||||
}
|
||||
|
||||
if (!adapter.isEmpty) {
|
||||
homeSets.postValue(adapter)
|
||||
idxHomeSet.postValue(0)
|
||||
db.query(ServiceDB.HomeSets._TABLE, arrayOf(ServiceDB.HomeSets.URL),
|
||||
"${ServiceDB.HomeSets.SERVICE_ID}=?", arrayOf(strServiceID), null, null, null).use { c ->
|
||||
while (c.moveToNext())
|
||||
info.homeSets += c.getString(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,75 +9,54 @@
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.Application
|
||||
import android.app.LoaderManager
|
||||
import android.content.AsyncTaskLoader
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Loader
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.NavUtils
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.Filter
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.NavUtils
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.databinding.ActivityCreateCalendarBinding
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.model.Service
|
||||
import at.bitfire.davdroid.ui.account.AccountActivity
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.ical4android.DateUtils
|
||||
import com.jaredrummler.android.colorpicker.ColorPickerDialog
|
||||
import com.jaredrummler.android.colorpicker.ColorPickerDialogListener
|
||||
import kotlinx.android.synthetic.main.activity_create_calendar.*
|
||||
import net.fortuna.ical4j.model.Calendar
|
||||
import okhttp3.HttpUrl
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import yuku.ambilwarna.AmbilWarnaDialog
|
||||
import java.util.*
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class CreateCalendarActivity: AppCompatActivity(), ColorPickerDialogListener {
|
||||
class CreateCalendarActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<CreateCalendarActivity.AccountInfo> {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_ACCOUNT = "account"
|
||||
val EXTRA_ACCOUNT = "account"
|
||||
}
|
||||
|
||||
private lateinit var model: Model
|
||||
private lateinit var account: Account
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
account = intent.extras.getParcelable(EXTRA_ACCOUNT)
|
||||
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
model = ViewModelProviders.of(this).get(Model::class.java)
|
||||
(intent?.getParcelableExtra(EXTRA_ACCOUNT) as? Account)?.let {
|
||||
model.initialize(it)
|
||||
setContentView(R.layout.activity_create_calendar)
|
||||
color.setOnClickListener { _ ->
|
||||
AmbilWarnaDialog(this, (color.background as ColorDrawable).color, true, object: AmbilWarnaDialog.OnAmbilWarnaListener {
|
||||
override fun onCancel(dialog: AmbilWarnaDialog) {}
|
||||
override fun onOk(dialog: AmbilWarnaDialog, rgb: Int) =
|
||||
color.setBackgroundColor(rgb)
|
||||
}).show()
|
||||
}
|
||||
|
||||
val binding = DataBindingUtil.setContentView<ActivityCreateCalendarBinding>(this, R.layout.activity_create_calendar)
|
||||
binding.lifecycleOwner = this
|
||||
binding.model = model
|
||||
|
||||
binding.color.setOnClickListener {
|
||||
ColorPickerDialog.newBuilder()
|
||||
.setShowAlphaSlider(false)
|
||||
.setColor((color.background as ColorDrawable).color)
|
||||
.show(this)
|
||||
}
|
||||
|
||||
binding.timezone.setAdapter(model.timezones)
|
||||
}
|
||||
|
||||
override fun onColorSelected(dialogId: Int, rgb: Int) {
|
||||
model.color.value = rgb
|
||||
}
|
||||
|
||||
override fun onDialogDismissed(dialogId: Int) {
|
||||
// color selection dismissed
|
||||
loaderManager.initLoader(0, null, this)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
@@ -88,163 +67,105 @@ class CreateCalendarActivity: AppCompatActivity(), ColorPickerDialogListener {
|
||||
override fun onOptionsItemSelected(item: MenuItem) =
|
||||
if (item.itemId == android.R.id.home) {
|
||||
val intent = Intent(this, AccountActivity::class.java)
|
||||
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, model.account)
|
||||
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
|
||||
NavUtils.navigateUpTo(this, intent)
|
||||
true
|
||||
} else
|
||||
false
|
||||
|
||||
fun onCreateCollection(item: MenuItem) {
|
||||
val homeSet = home_sets.selectedItem as String
|
||||
|
||||
var ok = true
|
||||
HttpUrl.parse(homeSet)?.let {
|
||||
val info = CollectionInfo(it.resolve(UUID.randomUUID().toString() + "/").toString())
|
||||
info.displayName = display_name.text.toString()
|
||||
if (info.displayName.isNullOrBlank()) {
|
||||
display_name.error = getString(R.string.create_collection_display_name_required)
|
||||
ok = false
|
||||
}
|
||||
|
||||
val args = Bundle()
|
||||
args.putString(CreateCollectionFragment.ARG_SERVICE_TYPE, Service.TYPE_CALDAV)
|
||||
info.description = StringUtils.trimToNull(description.text.toString())
|
||||
info.color = (color.background as ColorDrawable).color
|
||||
|
||||
val parent = model.homeSets.value?.getItem(model.idxHomeSet.value!!) ?: return
|
||||
args.putString(CreateCollectionFragment.ARG_URL, parent.url.resolve(UUID.randomUUID().toString() + "/").toString())
|
||||
|
||||
val displayName = model.displayName.value
|
||||
if (displayName.isNullOrBlank()) {
|
||||
model.displayNameError.value = getString(R.string.create_collection_display_name_required)
|
||||
ok = false
|
||||
} else {
|
||||
args.putString(CreateCollectionFragment.ARG_DISPLAY_NAME, displayName)
|
||||
model.displayNameError.value = null
|
||||
}
|
||||
|
||||
StringUtils.trimToNull(model.description.value)?.let {
|
||||
args.putString(CreateCollectionFragment.ARG_DESCRIPTION, it)
|
||||
}
|
||||
|
||||
model.color.value?.let {
|
||||
args.putInt(CreateCollectionFragment.ARG_COLOR, it)
|
||||
}
|
||||
|
||||
val tzId = model.timezone.value
|
||||
if (tzId.isNullOrBlank()) {
|
||||
model.timezoneError.value = getString(R.string.create_calendar_time_zone_required)
|
||||
ok = false
|
||||
} else {
|
||||
DateUtils.tzRegistry.getTimeZone(tzId)?.let { tz ->
|
||||
DateUtils.tzRegistry.getTimeZone(time_zone.selectedItem as String)?.let { tz ->
|
||||
val cal = Calendar()
|
||||
cal.components += tz.vTimeZone
|
||||
args.putString(CreateCollectionFragment.ARG_TIMEZONE, cal.toString())
|
||||
info.timeZone = cal.toString()
|
||||
}
|
||||
model.timezoneError.value = null
|
||||
}
|
||||
|
||||
val supportsVEVENT = model.supportVEVENT.value ?: false
|
||||
val supportsVTODO = model.supportVTODO.value ?: false
|
||||
val supportsVJOURNAL = model.supportVJOURNAL.value ?: false
|
||||
if (!supportsVEVENT && !supportsVTODO && !supportsVJOURNAL) {
|
||||
ok = false
|
||||
model.typeError.value = ""
|
||||
} else
|
||||
model.typeError.value = null
|
||||
when (type.checkedRadioButtonId) {
|
||||
R.id.type_events ->
|
||||
info.supportsVEVENT = true
|
||||
R.id.type_tasks ->
|
||||
info.supportsVTODO = true
|
||||
R.id.type_events_and_tasks -> {
|
||||
info.supportsVEVENT = true
|
||||
info.supportsVTODO = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!supportsVEVENT || !supportsVTODO || !supportsVJOURNAL) {
|
||||
// only if there's at least one component set not supported; don't include
|
||||
// information about supported components otherwise (means: everything supported)
|
||||
args.putBoolean(CreateCollectionFragment.ARG_SUPPORTS_VEVENT, supportsVEVENT)
|
||||
args.putBoolean(CreateCollectionFragment.ARG_SUPPORTS_VTODO, supportsVTODO)
|
||||
args.putBoolean(CreateCollectionFragment.ARG_SUPPORTS_VJOURNAL, supportsVJOURNAL)
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
args.putParcelable(CreateCollectionFragment.ARG_ACCOUNT, model.account)
|
||||
args.putString(CreateCollectionFragment.ARG_TYPE, Collection.TYPE_CALENDAR)
|
||||
val frag = CreateCollectionFragment()
|
||||
frag.arguments = args
|
||||
frag.show(supportFragmentManager, null)
|
||||
if (ok) {
|
||||
info.type = CollectionInfo.Type.CALENDAR
|
||||
CreateCollectionFragment.newInstance(account, info).show(supportFragmentManager, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TimeZoneAdapter(
|
||||
context: Context
|
||||
): ArrayAdapter<String>(context, android.R.layout.simple_list_item_1, android.R.id.text1) {
|
||||
|
||||
val tz = TimeZone.getAvailableIDs()!!
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) = AccountInfoLoader(this, account)
|
||||
|
||||
override fun getFilter(): Filter {
|
||||
return object: Filter() {
|
||||
override fun performFiltering(constraint: CharSequence?): FilterResults {
|
||||
val filtered = constraint?.let {
|
||||
tz.filter { it.contains(constraint, true) }
|
||||
} ?: listOf()
|
||||
val results = FilterResults()
|
||||
results.values = filtered
|
||||
results.count = filtered.size
|
||||
return results
|
||||
}
|
||||
override fun publishResults(constraint: CharSequence?, results: FilterResults) {
|
||||
clear()
|
||||
@Suppress("UNCHECKED_CAST") addAll(results.values as List<String>)
|
||||
if (results.count >= 0)
|
||||
notifyDataSetChanged()
|
||||
else
|
||||
notifyDataSetInvalidated()
|
||||
}
|
||||
override fun onLoadFinished(loader: Loader<AccountInfo>, info: AccountInfo?) {
|
||||
val timeZones = TimeZone.getAvailableIDs()
|
||||
time_zone.adapter = ArrayAdapter<String>(this, android.R.layout.simple_spinner_dropdown_item, timeZones)
|
||||
|
||||
// select system time zone
|
||||
val defaultTimeZone = TimeZone.getDefault().id
|
||||
for (i in 0 .. timeZones.size - 1)
|
||||
if (timeZones[i] == defaultTimeZone) {
|
||||
time_zone.setSelection(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
info?.let {
|
||||
home_sets.adapter = ArrayAdapter<String>(this, android.R.layout.simple_spinner_dropdown_item, info.homeSets)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<AccountInfo>) {}
|
||||
|
||||
class Model(
|
||||
application: Application
|
||||
): AndroidViewModel(application) {
|
||||
|
||||
var account: Account? = null
|
||||
class AccountInfo {
|
||||
val homeSets = LinkedList<String>()
|
||||
}
|
||||
|
||||
val displayName = MutableLiveData<String>()
|
||||
val displayNameError = MutableLiveData<String>()
|
||||
class AccountInfoLoader(
|
||||
context: Context,
|
||||
val account: Account
|
||||
): AsyncTaskLoader<AccountInfo>(context) {
|
||||
|
||||
val description = MutableLiveData<String>()
|
||||
val color = MutableLiveData<Int>()
|
||||
override fun onStartLoading() = forceLoad()
|
||||
|
||||
val homeSets = MutableLiveData<HomeSetAdapter>()
|
||||
val idxHomeSet = MutableLiveData<Int>()
|
||||
override fun loadInBackground(): AccountInfo? {
|
||||
val info = AccountInfo()
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.readableDatabase
|
||||
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 { cursor ->
|
||||
if (!cursor.moveToNext())
|
||||
return null
|
||||
val strServiceID = cursor.getString(0)
|
||||
|
||||
val timezones = TimeZoneAdapter(application)
|
||||
val timezone = MutableLiveData<String>()
|
||||
val timezoneError = MutableLiveData<String>()
|
||||
|
||||
val typeError = MutableLiveData<String>()
|
||||
val supportVEVENT = MutableLiveData<Boolean>()
|
||||
val supportVTODO = MutableLiveData<Boolean>()
|
||||
val supportVJOURNAL = MutableLiveData<Boolean>()
|
||||
|
||||
@MainThread
|
||||
fun initialize(account: Account) {
|
||||
if (this.account != null)
|
||||
return
|
||||
this.account = account
|
||||
|
||||
color.value = Constants.DAVDROID_GREEN_RGBA
|
||||
|
||||
timezone.value = TimeZone.getDefault().id
|
||||
|
||||
supportVEVENT.value = true
|
||||
supportVTODO.value = true
|
||||
supportVJOURNAL.value = true
|
||||
|
||||
thread {
|
||||
// load account info
|
||||
val adapter = HomeSetAdapter(getApplication())
|
||||
|
||||
val db = AppDatabase.getInstance(getApplication())
|
||||
db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV)?.let { service ->
|
||||
val homeSets = db.homeSetDao().getBindableByService(service.id)
|
||||
adapter.addAll(homeSets)
|
||||
}
|
||||
|
||||
if (!adapter.isEmpty) {
|
||||
homeSets.postValue(adapter)
|
||||
idxHomeSet.postValue(0)
|
||||
db.query(ServiceDB.HomeSets._TABLE, arrayOf(ServiceDB.HomeSets.URL),
|
||||
"${ServiceDB.HomeSets.SERVICE_ID}=?", arrayOf(strServiceID), null, null, null).use { c ->
|
||||
while (c.moveToNext())
|
||||
info.homeSets += c.getString(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,137 +9,97 @@
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.Application
|
||||
import android.app.Dialog
|
||||
import android.app.ProgressDialog
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.lifecycle.*
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import android.support.v4.app.DialogFragment
|
||||
import android.support.v4.app.LoaderManager
|
||||
import android.support.v4.content.AsyncTaskLoader
|
||||
import android.support.v4.content.Loader
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.XmlUtils
|
||||
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.AppDatabase
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import okhttp3.HttpUrl
|
||||
import java.io.IOException
|
||||
import java.io.StringWriter
|
||||
import java.util.logging.Level
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class CreateCollectionFragment: DialogFragment() {
|
||||
@Suppress("DEPRECATION")
|
||||
class CreateCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<Exception> {
|
||||
|
||||
companion object {
|
||||
const val ARG_ACCOUNT = "account"
|
||||
const val ARG_SERVICE_TYPE = "serviceType"
|
||||
|
||||
const val ARG_TYPE = "type"
|
||||
const val ARG_URL = "url"
|
||||
const val ARG_DISPLAY_NAME = "displayName"
|
||||
const val ARG_DESCRIPTION = "description"
|
||||
val ARG_ACCOUNT = "account"
|
||||
val ARG_COLLECTION_INFO = "collectionInfo"
|
||||
|
||||
fun newInstance(account: Account, info: CollectionInfo): CreateCollectionFragment {
|
||||
val frag = CreateCollectionFragment()
|
||||
val args = Bundle(2)
|
||||
args.putParcelable(ARG_ACCOUNT, account)
|
||||
args.putSerializable(ARG_COLLECTION_INFO, info)
|
||||
frag.arguments = args
|
||||
return frag
|
||||
}
|
||||
|
||||
// CalDAV only
|
||||
const val ARG_COLOR = "color"
|
||||
const val ARG_TIMEZONE = "timezone"
|
||||
const val ARG_SUPPORTS_VEVENT = "supportsVEVENT"
|
||||
const val ARG_SUPPORTS_VTODO = "supportsVTODO"
|
||||
const val ARG_SUPPORTS_VJOURNAL = "supportsVJOURNAL"
|
||||
}
|
||||
|
||||
private lateinit var model: Model
|
||||
private lateinit var account: Account
|
||||
private lateinit var info: CollectionInfo
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val args = arguments ?: throw IllegalArgumentException()
|
||||
|
||||
model = ViewModelProviders.of(this).get(Model::class.java)
|
||||
model.account = args.getParcelable(ARG_ACCOUNT) ?: throw IllegalArgumentException("ARG_ACCOUNT required")
|
||||
model.serviceType = args.getString(ARG_SERVICE_TYPE) ?: throw java.lang.IllegalArgumentException("ARG_SERVICE_TYPE required")
|
||||
account = arguments.getParcelable(ARG_ACCOUNT)
|
||||
info = arguments.getSerializable(ARG_COLLECTION_INFO) as CollectionInfo
|
||||
|
||||
model.collection = Collection(
|
||||
type = args.getString(ARG_TYPE) ?: throw IllegalArgumentException("ARG_TYPE required"),
|
||||
url = HttpUrl.parse(args.getString(ARG_URL) ?: throw IllegalArgumentException("ARG_URL required"))!!,
|
||||
displayName = args.getString(ARG_DISPLAY_NAME),
|
||||
description = args.getString(ARG_DESCRIPTION),
|
||||
|
||||
color = args.ifDefined(ARG_COLOR) { it.getInt(ARG_COLOR) },
|
||||
timezone = args.getString(ARG_TIMEZONE),
|
||||
supportsVEVENT = args.ifDefined(ARG_SUPPORTS_VEVENT) { it.getBoolean(ARG_SUPPORTS_VEVENT) },
|
||||
supportsVTODO = args.ifDefined(ARG_SUPPORTS_VTODO) { it.getBoolean(ARG_SUPPORTS_VTODO) },
|
||||
supportsVJOURNAL = args.ifDefined(ARG_SUPPORTS_VJOURNAL) { it.getBoolean(ARG_SUPPORTS_VJOURNAL) },
|
||||
|
||||
sync = true /* by default, sync collections which just have been created */
|
||||
)
|
||||
|
||||
model.createCollection().observe(this, Observer { exception ->
|
||||
if (exception == null)
|
||||
requireActivity().finish()
|
||||
else {
|
||||
dismiss()
|
||||
requireFragmentManager().beginTransaction()
|
||||
.add(ExceptionInfoFragment.newInstance(exception, model.account), null)
|
||||
.commit()
|
||||
}
|
||||
})
|
||||
loaderManager.initLoader(0, null, this)
|
||||
}
|
||||
|
||||
private fun<T> Bundle.ifDefined(name: String, getter: (Bundle) -> T): T? =
|
||||
if (containsKey(name))
|
||||
getter(this)
|
||||
else
|
||||
null
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val v = inflater.inflate(R.layout.create_collection, container, false)
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val progress = ProgressDialog(context)
|
||||
progress.setTitle(R.string.create_collection_creating)
|
||||
progress.setMessage(getString(R.string.please_wait))
|
||||
progress.isIndeterminate = true
|
||||
progress.setCanceledOnTouchOutside(false)
|
||||
isCancelable = false
|
||||
return v
|
||||
return progress
|
||||
}
|
||||
|
||||
|
||||
class Model(
|
||||
application: Application
|
||||
): AndroidViewModel(application) {
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) = CreateCollectionLoader(context, account, info)
|
||||
|
||||
lateinit var account: Account
|
||||
lateinit var serviceType: String
|
||||
lateinit var collection: Collection
|
||||
override fun onLoadFinished(loader: Loader<Exception>, exception: Exception?) {
|
||||
dismissAllowingStateLoss()
|
||||
|
||||
val result = MutableLiveData<Exception>()
|
||||
|
||||
fun createCollection(): LiveData<Exception> {
|
||||
thread {
|
||||
HttpClient.Builder(getApplication(), AccountSettings(getApplication(), account))
|
||||
.setForeground(true)
|
||||
.build().use { httpClient ->
|
||||
try {
|
||||
val dav = DavResource(httpClient.okHttpClient, collection.url)
|
||||
|
||||
// create collection on remote server
|
||||
dav.mkCol(generateXml()) {}
|
||||
|
||||
// no HTTP error -> create collection locally
|
||||
val db = AppDatabase.getInstance(getApplication())
|
||||
db.serviceDao().getByAccountAndType(account.name, serviceType)?.let { service ->
|
||||
collection.serviceId = service.id
|
||||
db.collectionDao().insert(collection)
|
||||
}
|
||||
|
||||
// post success
|
||||
result.postValue(null)
|
||||
} catch (e: Exception) {
|
||||
// post error
|
||||
result.postValue(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
activity?.let { parent ->
|
||||
if (exception != null)
|
||||
fragmentManager.beginTransaction()
|
||||
.add(ExceptionInfoFragment.newInstance(exception, account), null)
|
||||
.commitAllowingStateLoss()
|
||||
else
|
||||
parent.finish()
|
||||
}
|
||||
|
||||
private fun generateXml(): String {
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<Exception>) {}
|
||||
|
||||
|
||||
class CreateCollectionLoader(
|
||||
context: Context,
|
||||
val account: Account,
|
||||
val info: CollectionInfo
|
||||
): AsyncTaskLoader<Exception>(context) {
|
||||
|
||||
override fun onStartLoading() = forceLoad()
|
||||
|
||||
override fun loadInBackground(): Exception? {
|
||||
val writer = StringWriter()
|
||||
try {
|
||||
val serializer = XmlUtils.newSerializer()
|
||||
@@ -149,80 +109,72 @@ class CreateCollectionFragment: DialogFragment() {
|
||||
setPrefix("", XmlUtils.NS_WEBDAV)
|
||||
setPrefix("CAL", XmlUtils.NS_CALDAV)
|
||||
setPrefix("CARD", XmlUtils.NS_CARDDAV)
|
||||
|
||||
|
||||
startTag(XmlUtils.NS_WEBDAV, "mkcol")
|
||||
startTag(XmlUtils.NS_WEBDAV, "set")
|
||||
startTag(XmlUtils.NS_WEBDAV, "prop")
|
||||
startTag(XmlUtils.NS_WEBDAV, "resourcetype")
|
||||
startTag(XmlUtils.NS_WEBDAV, "collection")
|
||||
endTag(XmlUtils.NS_WEBDAV, "collection")
|
||||
if (collection.type == Collection.TYPE_ADDRESSBOOK) {
|
||||
startTag(XmlUtils.NS_CARDDAV, "addressbook")
|
||||
endTag(XmlUtils.NS_CARDDAV, "addressbook")
|
||||
} else if (collection.type == Collection.TYPE_CALENDAR) {
|
||||
startTag(XmlUtils.NS_CALDAV, "calendar")
|
||||
endTag(XmlUtils.NS_CALDAV, "calendar")
|
||||
}
|
||||
endTag(XmlUtils.NS_WEBDAV, "resourcetype")
|
||||
collection.displayName?.let {
|
||||
startTag(XmlUtils.NS_WEBDAV, "displayname")
|
||||
text(it)
|
||||
endTag(XmlUtils.NS_WEBDAV, "displayname")
|
||||
}
|
||||
startTag(XmlUtils.NS_WEBDAV, "set")
|
||||
startTag(XmlUtils.NS_WEBDAV, "prop")
|
||||
startTag(XmlUtils.NS_WEBDAV, "resourcetype")
|
||||
startTag(XmlUtils.NS_WEBDAV, "collection")
|
||||
endTag(XmlUtils.NS_WEBDAV, "collection")
|
||||
if (info.type == CollectionInfo.Type.ADDRESS_BOOK) {
|
||||
startTag(XmlUtils.NS_CARDDAV, "addressbook")
|
||||
endTag(XmlUtils.NS_CARDDAV, "addressbook")
|
||||
} else if (info.type == CollectionInfo.Type.CALENDAR) {
|
||||
startTag(XmlUtils.NS_CALDAV, "calendar")
|
||||
endTag(XmlUtils.NS_CALDAV, "calendar")
|
||||
}
|
||||
endTag(XmlUtils.NS_WEBDAV, "resourcetype")
|
||||
info.displayName?.let {
|
||||
startTag(XmlUtils.NS_WEBDAV, "displayname")
|
||||
text(it)
|
||||
endTag(XmlUtils.NS_WEBDAV, "displayname")
|
||||
}
|
||||
|
||||
// addressbook-specific properties
|
||||
if (info.type == CollectionInfo.Type.ADDRESS_BOOK) {
|
||||
info.description?.let {
|
||||
startTag(XmlUtils.NS_CARDDAV, "addressbook-description")
|
||||
text(it)
|
||||
endTag(XmlUtils.NS_CARDDAV, "addressbook-description")
|
||||
}
|
||||
}
|
||||
|
||||
// calendar-specific properties
|
||||
if (info.type == CollectionInfo.Type.CALENDAR) {
|
||||
info.description?.let {
|
||||
startTag(XmlUtils.NS_CALDAV, "calendar-description")
|
||||
text(it)
|
||||
endTag(XmlUtils.NS_CALDAV, "calendar-description")
|
||||
}
|
||||
|
||||
// addressbook-specific properties
|
||||
if (collection.type == Collection.TYPE_ADDRESSBOOK) {
|
||||
collection.description?.let {
|
||||
startTag(XmlUtils.NS_CARDDAV, "addressbook-description")
|
||||
text(it)
|
||||
endTag(XmlUtils.NS_CARDDAV, "addressbook-description")
|
||||
}
|
||||
}
|
||||
info.color?.let {
|
||||
startTag(XmlUtils.NS_APPLE_ICAL, "calendar-color")
|
||||
text(DavUtils.ARGBtoCalDAVColor(it))
|
||||
endTag(XmlUtils.NS_APPLE_ICAL, "calendar-color")
|
||||
}
|
||||
|
||||
// calendar-specific properties
|
||||
if (collection.type == Collection.TYPE_CALENDAR) {
|
||||
collection.description?.let {
|
||||
startTag(XmlUtils.NS_CALDAV, "calendar-description")
|
||||
text(it)
|
||||
endTag(XmlUtils.NS_CALDAV, "calendar-description")
|
||||
}
|
||||
|
||||
collection.color?.let {
|
||||
startTag(XmlUtils.NS_APPLE_ICAL, "calendar-color")
|
||||
text(DavUtils.ARGBtoCalDAVColor(it))
|
||||
endTag(XmlUtils.NS_APPLE_ICAL, "calendar-color")
|
||||
}
|
||||
|
||||
collection.timezone?.let {
|
||||
startTag(XmlUtils.NS_CALDAV, "calendar-timezone")
|
||||
cdsect(it)
|
||||
endTag(XmlUtils.NS_CALDAV, "calendar-timezone")
|
||||
}
|
||||
|
||||
if (collection.supportsVEVENT != null || collection.supportsVTODO != null || collection.supportsVJOURNAL != null) {
|
||||
// only if there's at least one explicitly supported calendar component set, otherwise don't include the property
|
||||
startTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set")
|
||||
if (collection.supportsVEVENT != false) {
|
||||
startTag(XmlUtils.NS_CALDAV, "comp")
|
||||
attribute(null, "name", "VEVENT")
|
||||
endTag(XmlUtils.NS_CALDAV, "comp")
|
||||
}
|
||||
if (collection.supportsVTODO != false) {
|
||||
startTag(XmlUtils.NS_CALDAV, "comp")
|
||||
attribute(null, "name", "VTODO")
|
||||
endTag(XmlUtils.NS_CALDAV, "comp")
|
||||
}
|
||||
if (collection.supportsVJOURNAL != false) {
|
||||
startTag(XmlUtils.NS_CALDAV, "comp")
|
||||
attribute(null, "name", "VJOURNAL")
|
||||
endTag(XmlUtils.NS_CALDAV, "comp")
|
||||
}
|
||||
endTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set")
|
||||
}
|
||||
}
|
||||
|
||||
endTag(XmlUtils.NS_WEBDAV, "prop")
|
||||
endTag(XmlUtils.NS_WEBDAV, "set")
|
||||
info.timeZone?.let {
|
||||
startTag(XmlUtils.NS_CALDAV, "calendar-timezone")
|
||||
cdsect(it)
|
||||
endTag(XmlUtils.NS_CALDAV, "calendar-timezone")
|
||||
}
|
||||
|
||||
startTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set")
|
||||
if (info.supportsVEVENT) {
|
||||
startTag(XmlUtils.NS_CALDAV, "comp")
|
||||
attribute(null, "name", "VEVENT")
|
||||
endTag(XmlUtils.NS_CALDAV, "comp")
|
||||
}
|
||||
if (info.supportsVTODO) {
|
||||
startTag(XmlUtils.NS_CALDAV, "comp")
|
||||
attribute(null, "name", "VTODO")
|
||||
endTag(XmlUtils.NS_CALDAV, "comp")
|
||||
}
|
||||
endTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set")
|
||||
}
|
||||
|
||||
endTag(XmlUtils.NS_WEBDAV, "prop")
|
||||
endTag(XmlUtils.NS_WEBDAV, "set")
|
||||
endTag(XmlUtils.NS_WEBDAV, "mkcol")
|
||||
endDocument()
|
||||
}
|
||||
@@ -230,9 +182,41 @@ class CreateCollectionFragment: DialogFragment() {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't assemble Extended MKCOL request", e)
|
||||
}
|
||||
|
||||
return writer.toString()
|
||||
}
|
||||
try {
|
||||
val client = HttpClient.create(context, account)
|
||||
val collection = DavResource(client, HttpUrl.parse(info.url)!!)
|
||||
|
||||
// create collection on remote server
|
||||
collection.mkCol(writer.toString())
|
||||
|
||||
// now insert collection into database:
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.writableDatabase
|
||||
|
||||
// 1. find service ID
|
||||
val serviceType = when (info.type) {
|
||||
CollectionInfo.Type.ADDRESS_BOOK -> ServiceDB.Services.SERVICE_CARDDAV
|
||||
CollectionInfo.Type.CALENDAR -> ServiceDB.Services.SERVICE_CALDAV
|
||||
else -> throw IllegalArgumentException("Collection must be an address book or calendar")
|
||||
}
|
||||
db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID),
|
||||
"${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
|
||||
arrayOf(account.name, serviceType), null, null, null).use { c ->
|
||||
|
||||
assert(c.moveToNext())
|
||||
val serviceID = c.getLong(0)
|
||||
|
||||
// 2. add collection to service
|
||||
val values = info.toDB()
|
||||
values.put(ServiceDB.Collections.SERVICE_ID, serviceID)
|
||||
db.insert(ServiceDB.Collections._TABLE, null, values)
|
||||
}
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
return e
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -11,12 +11,8 @@ package at.bitfire.davdroid.ui
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Application
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.app.LoaderManager
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
@@ -24,58 +20,49 @@ import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.util.Log
|
||||
import android.support.v4.content.ContextCompat
|
||||
import android.support.v4.content.FileProvider
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4android.exception.HttpException
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.databinding.ActivityDebugInfoBinding
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import kotlinx.android.synthetic.main.activity_debug_info.*
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.dmfs.tasks.contract.TaskContract
|
||||
import java.io.File
|
||||
import java.io.FileWriter
|
||||
import java.io.IOException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class DebugInfoActivity: AppCompatActivity() {
|
||||
class DebugInfoActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<String> {
|
||||
|
||||
companion object {
|
||||
const val KEY_THROWABLE = "throwable"
|
||||
const val KEY_LOGS = "logs"
|
||||
const val KEY_ACCOUNT = "account"
|
||||
const val KEY_AUTHORITY = "authority"
|
||||
const val KEY_LOCAL_RESOURCE = "localResource"
|
||||
const val KEY_REMOTE_RESOURCE = "remoteResource"
|
||||
@JvmField val KEY_THROWABLE = "throwable"
|
||||
@JvmField val KEY_LOGS = "logs"
|
||||
@JvmField val KEY_ACCOUNT = "account"
|
||||
@JvmField val KEY_AUTHORITY = "authority"
|
||||
@JvmField val KEY_PHASE = "phase"
|
||||
@JvmField val KEY_LOCAL_RESOURCE = "localResource"
|
||||
@JvmField val KEY_REMOTE_RESOURCE = "remoteResource"
|
||||
}
|
||||
|
||||
private lateinit var model: ReportModel
|
||||
var report: String? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_debug_info)
|
||||
|
||||
model = ViewModelProviders.of(this).get(ReportModel::class.java)
|
||||
model.initialize(intent.extras)
|
||||
|
||||
val binding = DataBindingUtil.setContentView<ActivityDebugInfoBinding>(this, R.layout.activity_debug_info)
|
||||
binding.model = model
|
||||
binding.lifecycleOwner = this
|
||||
loaderManager.initLoader(0, intent.extras, this)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
@@ -85,224 +72,201 @@ class DebugInfoActivity: AppCompatActivity() {
|
||||
|
||||
|
||||
fun onShare(item: MenuItem) {
|
||||
model.report.value?.let { report ->
|
||||
val builder = ShareCompat.IntentBuilder.from(this)
|
||||
.setSubject("${getString(R.string.app_name)} ${BuildConfig.VERSION_NAME} debug info")
|
||||
.setText(getString(R.string.debug_info_logs_attached))
|
||||
.setType("text/plain")
|
||||
report?.let {
|
||||
val sendIntent = Intent()
|
||||
sendIntent.action = Intent.ACTION_SEND
|
||||
sendIntent.type = "text/plain"
|
||||
sendIntent.putExtra(Intent.EXTRA_SUBJECT, "${getString(R.string.app_name)} ${BuildConfig.VERSION_NAME} debug info")
|
||||
|
||||
try {
|
||||
val debugInfoDir = File(filesDir, "debug")
|
||||
if (!(debugInfoDir.exists() && debugInfoDir.isDirectory) && !debugInfoDir.mkdir())
|
||||
throw IOException("Couldn't create debug directory")
|
||||
val debugInfoDir = File(cacheDir, "debug-info")
|
||||
debugInfoDir.mkdir()
|
||||
|
||||
val reportFile = File(debugInfoDir, "davx5-info.txt")
|
||||
val reportFile = File(debugInfoDir, "debug.txt")
|
||||
Logger.log.fine("Writing debug info to ${reportFile.absolutePath}")
|
||||
val writer = FileWriter(reportFile)
|
||||
writer.write(report)
|
||||
writer.write(it)
|
||||
writer.close()
|
||||
|
||||
builder.setStream(FileProvider.getUriForFile(this, getString(R.string.authority_debug_provider), reportFile))
|
||||
builder.intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
sendIntent.putExtra(Intent.EXTRA_STREAM, FileProvider.getUriForFile(this, getString(R.string.authority_log_provider), reportFile))
|
||||
sendIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
|
||||
} catch(e: IOException) {
|
||||
// creating an attachment failed, so send it inline
|
||||
val text = "Couldn't create debug info file: " + Log.getStackTraceString(e) + "\n\n$report"
|
||||
builder.setText(text)
|
||||
sendIntent.putExtra(Intent.EXTRA_TEXT, it)
|
||||
}
|
||||
|
||||
builder.startChooser()
|
||||
startActivity(Intent.createChooser(sendIntent, null))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ReportModel(application: Application): AndroidViewModel(application) {
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) =
|
||||
ReportLoader(this, args)
|
||||
|
||||
private var initialized = false
|
||||
val report = MutableLiveData<String>()
|
||||
override fun onLoadFinished(loader: Loader<String>, data: String?) {
|
||||
data?.let {
|
||||
report = it
|
||||
text_report.text = it
|
||||
}
|
||||
}
|
||||
|
||||
fun initialize(extras: Bundle?) {
|
||||
if (initialized)
|
||||
return
|
||||
override fun onLoaderReset(loader: Loader<String>) {}
|
||||
|
||||
Logger.log.info("Generating debug info report")
|
||||
initialized = true
|
||||
|
||||
thread {
|
||||
val context = getApplication<Application>()
|
||||
val text = StringBuilder("--- BEGIN DEBUG INFO ---\n")
|
||||
class ReportLoader(
|
||||
context: Context,
|
||||
val extras: Bundle?
|
||||
): AsyncTaskLoader<String>(context) {
|
||||
|
||||
// begin with most specific information
|
||||
extras?.getParcelable<Account>(KEY_ACCOUNT)?.let {
|
||||
text.append("SYNCHRONIZATION INFO\nAccount name: ${it.name}\n")
|
||||
}
|
||||
extras?.getString(KEY_AUTHORITY)?.let {
|
||||
text.append("Authority: $it\n")
|
||||
}
|
||||
override fun onStartLoading() = forceLoad()
|
||||
|
||||
// exception details
|
||||
val throwable = extras?.getSerializable(KEY_THROWABLE) as Throwable?
|
||||
if (throwable is HttpException) {
|
||||
throwable.request?.let { request ->
|
||||
text.append("\nHTTP REQUEST:\n$request\n")
|
||||
throwable.requestBody?.let { text.append(it) }
|
||||
text.append("\n\n")
|
||||
}
|
||||
throwable.response?.let { response ->
|
||||
text.append("HTTP RESPONSE:\n$response\n")
|
||||
throwable.responseBody?.let { text.append(it) }
|
||||
text.append("\n\n")
|
||||
}
|
||||
}
|
||||
override fun loadInBackground(): String {
|
||||
val report = StringBuilder("--- BEGIN DEBUG INFO ---\n")
|
||||
|
||||
extras?.getString(KEY_LOCAL_RESOURCE)?.let {
|
||||
text.append("\nLOCAL RESOURCE:\n$it\n")
|
||||
}
|
||||
extras?.getString(KEY_REMOTE_RESOURCE)?.let {
|
||||
text.append("\nREMOTE RESOURCE:\n$it\n")
|
||||
}
|
||||
|
||||
throwable?.let {
|
||||
// Log.getStackTraceString(e) returns "" in case of UnknownHostException
|
||||
text.append("\nEXCEPTION:\n${ExceptionUtils.getStackTrace(throwable)}")
|
||||
}
|
||||
|
||||
// logs (for instance, from failed resource detection)
|
||||
extras?.getString(KEY_LOGS)?.let {
|
||||
text.append("\nLOGS:\n$it\n")
|
||||
}
|
||||
|
||||
// software information
|
||||
try {
|
||||
text.append("\nSOFTWARE INFORMATION\n")
|
||||
val pm = context.packageManager
|
||||
val appIDs = mutableSetOf( // we always want info about these packages
|
||||
BuildConfig.APPLICATION_ID, // DAVx5
|
||||
"${BuildConfig.APPLICATION_ID}.jbworkaround", // DAVdroid JB Workaround
|
||||
"org.dmfs.tasks" // OpenTasks
|
||||
)
|
||||
// add info about contact, calendar, task provider
|
||||
for (authority in arrayOf(ContactsContract.AUTHORITY, CalendarContract.AUTHORITY, TaskProvider.ProviderName.OpenTasks.authority))
|
||||
pm.resolveContentProvider(authority, 0)?.let { appIDs += it.packageName }
|
||||
// add info about available contact, calendar, task apps
|
||||
for (uri in arrayOf(ContactsContract.Contacts.CONTENT_URI, CalendarContract.Events.CONTENT_URI, TaskContract.Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority))) {
|
||||
val viewIntent = Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(uri, 1))
|
||||
for (info in pm.queryIntentActivities(viewIntent, 0))
|
||||
appIDs += info.activityInfo.packageName
|
||||
}
|
||||
|
||||
for (appID in appIDs)
|
||||
try {
|
||||
val info = pm.getPackageInfo(appID, 0)
|
||||
text .append("* ").append(appID)
|
||||
.append(" ").append(info.versionName)
|
||||
.append(" (").append(PackageInfoCompat.getLongVersionCode(info)).append(")")
|
||||
pm.getInstallerPackageName(appID)?.let { installer ->
|
||||
text.append(" from ").append(installer)
|
||||
}
|
||||
info.applicationInfo?.let { applicationInfo ->
|
||||
if (!applicationInfo.enabled)
|
||||
text.append(" disabled!")
|
||||
if (applicationInfo.flags.and(ApplicationInfo.FLAG_EXTERNAL_STORAGE) != 0)
|
||||
text.append(" on external storage!")
|
||||
}
|
||||
text.append("\n")
|
||||
} catch(e: PackageManager.NameNotFoundException) {
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't get software information", e)
|
||||
}
|
||||
|
||||
// connectivity
|
||||
text.append("\nCONNECTIVITY (at the moment)\n")
|
||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
connectivityManager.activeNetworkInfo?.let { networkInfo ->
|
||||
val type = when (networkInfo.type) {
|
||||
ConnectivityManager.TYPE_WIFI -> "WiFi"
|
||||
ConnectivityManager.TYPE_MOBILE -> "mobile"
|
||||
else -> "type: ${networkInfo.type}"
|
||||
}
|
||||
text.append("Active connection: $type, ${networkInfo.detailedState}\n")
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 23)
|
||||
connectivityManager.defaultProxy?.let { proxy ->
|
||||
text.append("System default proxy: ${proxy.host}:${proxy.port}")
|
||||
}
|
||||
text.append("\n")
|
||||
|
||||
text.append("CONFIGURATION\n")
|
||||
// power saving
|
||||
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
if (Build.VERSION.SDK_INT >= 23)
|
||||
text.append("Power saving disabled: ")
|
||||
.append(if (powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)) "yes" else "no")
|
||||
.append("\n")
|
||||
// permissions
|
||||
for (permission in arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS,
|
||||
Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR,
|
||||
TaskProvider.PERMISSION_READ_TASKS, TaskProvider.PERMISSION_WRITE_TASKS,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION)) {
|
||||
text .append(permission).append(": ")
|
||||
.append(if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED)
|
||||
"granted"
|
||||
else
|
||||
"denied")
|
||||
.append("\n")
|
||||
}
|
||||
// system-wide sync settings
|
||||
text.append("System-wide synchronization: ")
|
||||
.append(if (ContentResolver.getMasterSyncAutomatically()) "automatically" else "manually")
|
||||
.append("\n")
|
||||
// main accounts
|
||||
val accountManager = AccountManager.get(context)
|
||||
for (acct in accountManager.getAccountsByType(context.getString(R.string.account_type)))
|
||||
try {
|
||||
val accountSettings = AccountSettings(context, acct)
|
||||
text.append("Account: ${acct.name}\n" +
|
||||
" Address book sync. interval: ${syncStatus(accountSettings, context.getString(R.string.address_books_authority))}\n" +
|
||||
" Calendar sync. interval: ${syncStatus(accountSettings, CalendarContract.AUTHORITY)}\n" +
|
||||
" OpenTasks sync. interval: ${syncStatus(accountSettings, TaskProvider.ProviderName.OpenTasks.authority)}\n" +
|
||||
" WiFi only: ").append(accountSettings.getSyncWifiOnly())
|
||||
accountSettings.getSyncWifiOnlySSIDs()?.let {
|
||||
text.append(", SSIDs: ${accountSettings.getSyncWifiOnlySSIDs()}")
|
||||
}
|
||||
text.append("\n [CardDAV] Contact group method: ${accountSettings.getGroupMethod()}")
|
||||
.append("\n [CalDAV] Time range (past days): ${accountSettings.getTimeRangePastDays()}")
|
||||
.append("\n Manage calendar colors: ${accountSettings.getManageCalendarColors()}")
|
||||
.append("\n Use event colors: ${accountSettings.getEventColors()}")
|
||||
.append("\n")
|
||||
} catch (e: InvalidAccountException) {
|
||||
text.append("$acct is invalid (unsupported settings version) or does not exist\n")
|
||||
}
|
||||
// address book accounts
|
||||
for (acct in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)))
|
||||
try {
|
||||
val addressBook = LocalAddressBook(context, acct, null)
|
||||
text.append("Address book account: ${acct.name}\n" +
|
||||
" Main account: ${addressBook.mainAccount}\n" +
|
||||
" URL: ${addressBook.url}\n" +
|
||||
" Sync automatically: ").append(ContentResolver.getSyncAutomatically(acct, ContactsContract.AUTHORITY)).append("\n")
|
||||
} catch(e: Exception) {
|
||||
text.append("$acct is invalid: ${e.message}\n")
|
||||
}
|
||||
text.append("\n")
|
||||
|
||||
text.append("SQLITE DUMP\n")
|
||||
AppDatabase.getInstance(context).dump(text)
|
||||
text.append("\n")
|
||||
|
||||
try {
|
||||
text.append(
|
||||
"SYSTEM INFORMATION\n" +
|
||||
"Android version: ${Build.VERSION.RELEASE} (${Build.DISPLAY})\n" +
|
||||
"Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})\n\n"
|
||||
)
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't get system details", e)
|
||||
}
|
||||
|
||||
text.append("--- END DEBUG INFO ---\n")
|
||||
report.postValue(text.toString())
|
||||
// begin with most specific information
|
||||
extras?.getInt(KEY_PHASE, -1).takeIf { it != -1 }?.let {
|
||||
report.append("SYNCHRONIZATION INFO\nSynchronization phase: $it\n")
|
||||
}
|
||||
extras?.getParcelable<Account>(KEY_ACCOUNT)?.let {
|
||||
report.append("Account name: ${it.name}\n")
|
||||
}
|
||||
extras?.getString(KEY_AUTHORITY)?.let {
|
||||
report.append("Authority: $it\n")
|
||||
}
|
||||
|
||||
// exception details
|
||||
val throwable = extras?.getSerializable(KEY_THROWABLE) as Throwable?
|
||||
if (throwable is HttpException) {
|
||||
throwable.request?.let {
|
||||
report.append("\nHTTP REQUEST:\n$it\n\n")
|
||||
}
|
||||
throwable.response?.let {
|
||||
report.append("HTTP RESPONSE:\n$it\n")
|
||||
}
|
||||
}
|
||||
|
||||
extras?.getString(KEY_LOCAL_RESOURCE)?.let {
|
||||
report.append("\nLOCAL RESOURCE:\n$it\n")
|
||||
}
|
||||
extras?.getString(KEY_REMOTE_RESOURCE)?.let {
|
||||
report.append("\nREMOTE RESOURCE:\n$it\n")
|
||||
}
|
||||
|
||||
throwable?.let {
|
||||
report.append("\nEXCEPTION:\n${ExceptionUtils.getStackTrace(throwable)}")
|
||||
}
|
||||
|
||||
// logs (for instance, from failed resource detection)
|
||||
extras?.getString(KEY_LOGS)?.let {
|
||||
report.append("\nLOGS:\n$it\n")
|
||||
}
|
||||
|
||||
// software information
|
||||
try {
|
||||
val pm = context.packageManager
|
||||
val installedFrom = pm.getInstallerPackageName(BuildConfig.APPLICATION_ID) ?: "APK (directly)"
|
||||
var workaroundInstalled = false
|
||||
try {
|
||||
workaroundInstalled = pm.getPackageInfo("${BuildConfig.APPLICATION_ID}.jbworkaround", 0) != null
|
||||
} catch(e: PackageManager.NameNotFoundException) {
|
||||
}
|
||||
val formatter = SimpleDateFormat.getDateInstance()
|
||||
report.append("\nSOFTWARE INFORMATION\n" +
|
||||
"Package: ${BuildConfig.APPLICATION_ID}\n" +
|
||||
"Version: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) from ${formatter.format(Date(BuildConfig.buildTime))}\n")
|
||||
.append("Installed from: $installedFrom\n")
|
||||
.append("JB Workaround installed: ${if (workaroundInstalled) "yes" else "no"}\n\n")
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't get software information", e)
|
||||
}
|
||||
|
||||
// connectivity
|
||||
report.append("CONNECTIVITY (at the moment)\n")
|
||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
connectivityManager.activeNetworkInfo?.let { networkInfo ->
|
||||
val type = when (networkInfo.type) {
|
||||
ConnectivityManager.TYPE_WIFI -> "WiFi"
|
||||
ConnectivityManager.TYPE_MOBILE -> "mobile"
|
||||
else -> "type: ${networkInfo.type}"
|
||||
}
|
||||
report.append("Active connection: $type, ${networkInfo.detailedState}\n")
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 23)
|
||||
connectivityManager.defaultProxy?.let { proxy ->
|
||||
report.append("System default proxy: ${proxy.host}:${proxy.port}")
|
||||
}
|
||||
report.append("\n")
|
||||
|
||||
report.append("CONFIGURATION\n")
|
||||
// power saving
|
||||
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
if (Build.VERSION.SDK_INT >= 23)
|
||||
report.append("Power saving disabled: ")
|
||||
.append(if (powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)) "yes" else "no")
|
||||
.append("\n")
|
||||
// permissions
|
||||
for (permission in arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS,
|
||||
Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR,
|
||||
PermissionsActivity.PERMISSION_READ_TASKS, PermissionsActivity.PERMISSION_WRITE_TASKS))
|
||||
report.append(permission).append(" permission: ")
|
||||
.append(if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED) "granted" else "denied")
|
||||
.append("\n")
|
||||
// system-wide sync settings
|
||||
report.append("System-wide synchronization: ")
|
||||
.append(if (ContentResolver.getMasterSyncAutomatically()) "automatically" else "manually")
|
||||
.append("\n")
|
||||
// main accounts
|
||||
val accountManager = AccountManager.get(context)
|
||||
for (acct in accountManager.getAccountsByType(context.getString(R.string.account_type)))
|
||||
try {
|
||||
val settings = AccountSettings(context, acct)
|
||||
report.append("Account: ${acct.name}\n" +
|
||||
" Address book sync. interval: ${syncStatus(settings, context.getString(R.string.address_books_authority))}\n" +
|
||||
" Calendar sync. interval: ${syncStatus(settings, CalendarContract.AUTHORITY)}\n" +
|
||||
" OpenTasks sync. interval: ${syncStatus(settings, TaskProvider.ProviderName.OpenTasks.authority)}\n" +
|
||||
" WiFi only: ").append(settings.getSyncWifiOnly())
|
||||
settings.getSyncWifiOnlySSIDs()?.let {
|
||||
report.append(", SSIDs: ${settings.getSyncWifiOnlySSIDs()}")
|
||||
}
|
||||
report.append("\n [CardDAV] Contact group method: ${settings.getGroupMethod()}")
|
||||
.append("\n [CalDAV] Time range (past days): ${settings.getTimeRangePastDays()}")
|
||||
.append("\n Manage calendar colors: ${settings.getManageCalendarColors()}")
|
||||
.append("\n")
|
||||
} catch(e: InvalidAccountException) {
|
||||
report.append("$acct is invalid (unsupported settings version) or does not exist\n")
|
||||
}
|
||||
// address book accounts
|
||||
for (acct in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)))
|
||||
try {
|
||||
val addressBook = LocalAddressBook(context, acct, null)
|
||||
report.append("Address book account: ${acct.name}\n" +
|
||||
" Main account: ${addressBook.getMainAccount()}\n" +
|
||||
" URL: ${addressBook.getURL()}\n" +
|
||||
" Sync automatically: ").append(ContentResolver.getSyncAutomatically(acct, ContactsContract.AUTHORITY)).append("\n")
|
||||
} catch(e: ContactsStorageException) {
|
||||
report.append("$acct is invalid: ${e.message}\n")
|
||||
}
|
||||
report.append("\n")
|
||||
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
report.append("SQLITE DUMP\n")
|
||||
dbHelper.dump(report)
|
||||
report.append("\n")
|
||||
}
|
||||
|
||||
try {
|
||||
report.append(
|
||||
"SYSTEM INFORMATION\n" +
|
||||
"Android version: ${Build.VERSION.RELEASE} (${Build.DISPLAY})\n" +
|
||||
"Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})\n\n"
|
||||
)
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't get system details", e)
|
||||
}
|
||||
|
||||
report.append("--- END DEBUG INFO ---\n")
|
||||
return report.toString()
|
||||
}
|
||||
|
||||
private fun syncStatus(settings: AccountSettings, authority: String): String {
|
||||
@@ -312,7 +276,6 @@ class DebugInfoActivity: AppCompatActivity() {
|
||||
} else
|
||||
"—"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.R
|
||||
|
||||
class DefaultAccountsDrawerHandler: IAccountsDrawerHandler {
|
||||
|
||||
companion object {
|
||||
private const val BETA_FEEDBACK_URI = "mailto:play@bitfire.at?subject=${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} feedback (${BuildConfig.VERSION_CODE})"
|
||||
}
|
||||
|
||||
|
||||
override fun initMenu(context: Context, menu: Menu) {
|
||||
if (BuildConfig.VERSION_NAME.contains("-beta") || BuildConfig.VERSION_NAME.contains("-rc"))
|
||||
menu.findItem(R.id.nav_beta_feedback).isVisible = true
|
||||
}
|
||||
|
||||
override fun onNavigationItemSelected(activity: Activity, item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.nav_about ->
|
||||
activity.startActivity(Intent(activity, AboutActivity::class.java))
|
||||
R.id.nav_app_settings ->
|
||||
activity.startActivity(Intent(activity, AppSettingsActivity::class.java))
|
||||
R.id.nav_beta_feedback ->
|
||||
UiUtils.launchUri(activity, Uri.parse(BETA_FEEDBACK_URI), Intent.ACTION_SENDTO)
|
||||
R.id.nav_twitter ->
|
||||
UiUtils.launchUri(activity, Uri.parse("https://twitter.com/" + activity.getString(R.string.twitter_handle)))
|
||||
R.id.nav_website ->
|
||||
UiUtils.launchUri(activity, App.homepageUrl(activity))
|
||||
R.id.nav_manual ->
|
||||
UiUtils.launchUri(activity, App.homepageUrl(activity)
|
||||
.buildUpon().appendPath("manual").build())
|
||||
R.id.nav_faq ->
|
||||
UiUtils.launchUri(activity, App.homepageUrl(activity)
|
||||
.buildUpon().appendPath("faq").build())
|
||||
R.id.nav_forums ->
|
||||
UiUtils.launchUri(activity, App.homepageUrl(activity)
|
||||
.buildUpon().appendPath("forums").build())
|
||||
R.id.nav_donate ->
|
||||
//if (BuildConfig.FLAVOR != App.FLAVOR_GOOGLE_PLAY)
|
||||
UiUtils.launchUri(activity, App.homepageUrl(activity)
|
||||
.buildUpon().appendPath("donate").build())
|
||||
else ->
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,129 +9,135 @@
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.Application
|
||||
import android.app.Dialog
|
||||
import android.app.ProgressDialog
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.lifecycle.*
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import android.support.v4.app.DialogFragment
|
||||
import android.support.v4.app.LoaderManager
|
||||
import android.support.v4.content.AsyncTaskLoader
|
||||
import android.support.v4.content.Loader
|
||||
import android.support.v7.app.AlertDialog
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.davdroid.HttpClient
|
||||
import at.bitfire.davdroid.databinding.DeleteCollectionBinding
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import kotlin.concurrent.thread
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
class DeleteCollectionFragment: DialogFragment() {
|
||||
@Suppress("DEPRECATION")
|
||||
class DeleteCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<Exception> {
|
||||
|
||||
companion object {
|
||||
const val ARG_ACCOUNT = "account"
|
||||
const val ARG_COLLECTION_ID = "collectionId"
|
||||
|
||||
fun newInstance(account: Account, collectionId: Long): DialogFragment {
|
||||
val frag = DeleteCollectionFragment()
|
||||
val args = Bundle(2)
|
||||
args.putParcelable(ARG_ACCOUNT, account)
|
||||
args.putLong(ARG_COLLECTION_ID, collectionId)
|
||||
frag.arguments = args
|
||||
return frag
|
||||
}
|
||||
val ARG_ACCOUNT = "account"
|
||||
val ARG_COLLECTION_INFO = "collectionInfo"
|
||||
}
|
||||
|
||||
private lateinit var model: DeleteCollectionModel
|
||||
private lateinit var account: Account
|
||||
private lateinit var collectionInfo: CollectionInfo
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
model = ViewModelProviders.of(this).get(DeleteCollectionModel::class.java)
|
||||
model.initialize(
|
||||
arguments!!.getParcelable(ARG_ACCOUNT)!!,
|
||||
arguments!!.getLong(ARG_COLLECTION_ID)
|
||||
)
|
||||
|
||||
account = arguments.getParcelable(ARG_ACCOUNT)
|
||||
collectionInfo = arguments.getSerializable(ARG_COLLECTION_INFO) as CollectionInfo
|
||||
|
||||
loaderManager.initLoader(0, null, this)
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
val binding = DeleteCollectionBinding.inflate(layoutInflater, null, false)
|
||||
binding.lifecycleOwner = this
|
||||
binding.model = model
|
||||
|
||||
binding.ok.setOnClickListener {
|
||||
isCancelable = false
|
||||
binding.progress.visibility = View.VISIBLE
|
||||
binding.controls.visibility = View.GONE
|
||||
|
||||
model.deleteCollection().observe(this, Observer { exception ->
|
||||
if (exception != null)
|
||||
requireFragmentManager().beginTransaction()
|
||||
.add(ExceptionInfoFragment.newInstance(exception, model.account), null)
|
||||
.commit()
|
||||
dismiss()
|
||||
})
|
||||
}
|
||||
|
||||
binding.cancel.setOnClickListener {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
return binding.root
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val progress = ProgressDialog(context)
|
||||
progress.setTitle(R.string.delete_collection_deleting_collection)
|
||||
progress.setMessage(getString(R.string.please_wait))
|
||||
progress.isIndeterminate = true
|
||||
progress.setCanceledOnTouchOutside(false)
|
||||
isCancelable = false
|
||||
return progress
|
||||
}
|
||||
|
||||
|
||||
class DeleteCollectionModel(
|
||||
application: Application
|
||||
): AndroidViewModel(application) {
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) =
|
||||
DeleteCollectionLoader(context, account, collectionInfo)
|
||||
|
||||
var account: Account? = null
|
||||
var collectionInfo: Collection? = null
|
||||
override fun onLoadFinished(loader: Loader<Exception>, exception: Exception?) {
|
||||
dismissAllowingStateLoss()
|
||||
|
||||
val db = AppDatabase.getInstance(application)
|
||||
if (exception != null)
|
||||
fragmentManager.beginTransaction()
|
||||
.add(ExceptionInfoFragment.newInstance(exception, account), null)
|
||||
.commitAllowingStateLoss()
|
||||
else
|
||||
(activity as? AccountActivity)?.reload()
|
||||
}
|
||||
|
||||
val confirmation = MutableLiveData<Boolean>()
|
||||
val result = MutableLiveData<Exception>()
|
||||
override fun onLoaderReset(loader: Loader<Exception>) {}
|
||||
|
||||
@MainThread
|
||||
fun initialize(account: Account, collectionId: Long) {
|
||||
if (this.account == null)
|
||||
this.account = account
|
||||
|
||||
if (collectionInfo == null)
|
||||
thread {
|
||||
collectionInfo = db.collectionDao().get(collectionId)
|
||||
class DeleteCollectionLoader(
|
||||
context: Context,
|
||||
val account: Account,
|
||||
val collectionInfo: CollectionInfo
|
||||
): AsyncTaskLoader<Exception>(context) {
|
||||
|
||||
override fun onStartLoading() = forceLoad()
|
||||
|
||||
override fun loadInBackground(): Exception? {
|
||||
try {
|
||||
val httpClient = HttpClient.create(context, account)
|
||||
val collection = DavResource(httpClient, HttpUrl.parse(collectionInfo.url)!!)
|
||||
|
||||
// delete collection from server
|
||||
collection.delete(null)
|
||||
|
||||
// delete collection locally
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.writableDatabase
|
||||
db.delete(ServiceDB.Collections._TABLE, "${ServiceDB.Collections.ID}=?", arrayOf(collectionInfo.id.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteCollection(): LiveData<Exception> {
|
||||
thread {
|
||||
val account = account ?: return@thread
|
||||
val collectionInfo = collectionInfo ?: return@thread
|
||||
|
||||
val context = getApplication<Application>()
|
||||
HttpClient.Builder(context, AccountSettings(context, account))
|
||||
.setForeground(true)
|
||||
.build().use { httpClient ->
|
||||
try {
|
||||
val collection = DavResource(httpClient.okHttpClient, collectionInfo.url)
|
||||
|
||||
// delete collection from server
|
||||
collection.delete(null) {}
|
||||
|
||||
// delete collection locally
|
||||
db.collectionDao().delete(collectionInfo)
|
||||
|
||||
// return success
|
||||
result.postValue(null)
|
||||
|
||||
} catch(e: Exception) {
|
||||
// return error
|
||||
result.postValue(e)
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch(e: Exception) {
|
||||
return e
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ConfirmDeleteCollectionFragment: DialogFragment() {
|
||||
|
||||
companion object {
|
||||
|
||||
fun newInstance(account: Account, collectionInfo: CollectionInfo): DialogFragment {
|
||||
val frag = ConfirmDeleteCollectionFragment()
|
||||
val args = Bundle(2)
|
||||
args.putParcelable(ARG_ACCOUNT, account)
|
||||
args.putSerializable(ARG_COLLECTION_INFO, collectionInfo)
|
||||
frag.arguments = args
|
||||
return frag
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val collectionInfo = arguments.getSerializable(ARG_COLLECTION_INFO) as CollectionInfo
|
||||
val name = if (collectionInfo.displayName.isNullOrBlank())
|
||||
collectionInfo.url
|
||||
else
|
||||
collectionInfo.displayName
|
||||
|
||||
return AlertDialog.Builder(context)
|
||||
.setTitle(R.string.delete_collection_confirm_title)
|
||||
.setMessage(getString(R.string.delete_collection_confirm_warning, name))
|
||||
.setPositiveButton(android.R.string.yes, { _, _ ->
|
||||
val frag = DeleteCollectionFragment()
|
||||
frag.arguments = arguments
|
||||
frag.show(fragmentManager, null)
|
||||
})
|
||||
.setNegativeButton(android.R.string.no, { _, _ ->
|
||||
dismiss()
|
||||
})
|
||||
.create()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -12,17 +12,17 @@ import android.accounts.Account
|
||||
import android.app.Dialog
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import android.support.v4.app.DialogFragment
|
||||
import android.support.v7.app.AlertDialog
|
||||
import at.bitfire.dav4android.exception.HttpException
|
||||
import at.bitfire.davdroid.R
|
||||
import java.io.IOException
|
||||
|
||||
class ExceptionInfoFragment: DialogFragment() {
|
||||
|
||||
companion object {
|
||||
const val ARG_ACCOUNT = "account"
|
||||
const val ARG_EXCEPTION = "exception"
|
||||
val ARG_ACCOUNT = "account"
|
||||
val ARG_EXCEPTION = "exception"
|
||||
|
||||
fun newInstance(exception: Exception, account: Account?): ExceptionInfoFragment {
|
||||
val frag = ExceptionInfoFragment()
|
||||
@@ -35,9 +35,8 @@ class ExceptionInfoFragment: DialogFragment() {
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val args = requireNotNull(arguments)
|
||||
val exception = args.getSerializable(ARG_EXCEPTION) as Exception
|
||||
val account: Account? = args.getParcelable(ARG_ACCOUNT)
|
||||
val exception = arguments.getSerializable(ARG_EXCEPTION) as Exception
|
||||
val account: Account? = arguments.getParcelable(ARG_ACCOUNT)
|
||||
|
||||
val title = when (exception) {
|
||||
is HttpException -> R.string.exception_httpexception
|
||||
@@ -45,17 +44,17 @@ class ExceptionInfoFragment: DialogFragment() {
|
||||
else -> R.string.exception
|
||||
}
|
||||
|
||||
val dialog = AlertDialog.Builder(requireActivity())
|
||||
val dialog = AlertDialog.Builder(context)
|
||||
.setIcon(R.drawable.ic_error_dark)
|
||||
.setTitle(title)
|
||||
.setMessage(exception::class.java.name + "\n" + exception.localizedMessage)
|
||||
.setNegativeButton(R.string.exception_show_details) { _, _ ->
|
||||
val intent = Intent(activity, DebugInfoActivity::class.java)
|
||||
.setNegativeButton(R.string.exception_show_details, { _, _ ->
|
||||
val intent = Intent(getContext(), DebugInfoActivity::class.java)
|
||||
intent.putExtra(DebugInfoActivity.KEY_THROWABLE, exception)
|
||||
account?.let { intent.putExtra(DebugInfoActivity.KEY_ACCOUNT, it) }
|
||||
startActivity(intent)
|
||||
}
|
||||
.setPositiveButton(android.R.string.ok) { _, _ -> }
|
||||
})
|
||||
.setPositiveButton(android.R.string.ok, { _, _ -> })
|
||||
.create()
|
||||
isCancelable = false
|
||||
return dialog
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.text.TextUtils
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.TextView
|
||||
import at.bitfire.davdroid.model.HomeSet
|
||||
|
||||
class HomeSetAdapter(
|
||||
context: Context
|
||||
): ArrayAdapter<HomeSet>(context, android.R.layout.simple_list_item_2, android.R.id.text1) {
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val data = getItem(position)!!
|
||||
|
||||
val v: View
|
||||
if (!data.displayName.isNullOrBlank()) {
|
||||
v = convertView ?: LayoutInflater.from(context).inflate(android.R.layout.simple_list_item_2, null, false)
|
||||
v.findViewById<TextView>(android.R.id.text1).text = data.displayName
|
||||
v.findViewById<TextView>(android.R.id.text2).apply {
|
||||
text = data.url.toString()
|
||||
setSingleLine()
|
||||
ellipsize = TextUtils.TruncateAt.START
|
||||
}
|
||||
} else {
|
||||
v = convertView ?: LayoutInflater.from(context).inflate(android.R.layout.simple_list_item_1, null, false)
|
||||
v.findViewById<TextView>(android.R.id.text1).apply {
|
||||
text = data.url.toString()
|
||||
setSingleLine()
|
||||
ellipsize = TextUtils.TruncateAt.START
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup) =
|
||||
getView(position, convertView, parent)
|
||||
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
|
||||
interface IAccountsDrawerHandler {
|
||||
|
||||
fun initMenu(context: Context, menu: Menu)
|
||||
|
||||
fun onNavigationItemSelected(activity: Activity, item: MenuItem): Boolean
|
||||
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationChannelGroup
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.R
|
||||
|
||||
object NotificationUtils {
|
||||
|
||||
// notification IDs
|
||||
const val NOTIFY_EXTERNAL_FILE_LOGGING = 1
|
||||
const val NOTIFY_REFRESH_COLLECTIONS = 2
|
||||
const val NOTIFY_SYNC_ERROR = 10
|
||||
const val NOTIFY_INVALID_RESOURCE = 11
|
||||
const val NOTIFY_OPENTASKS = 20
|
||||
const val NOTIFY_PERMISSIONS = 21
|
||||
const val NOTIFY_LICENSE = 100
|
||||
|
||||
// notification channels
|
||||
const val CHANNEL_GENERAL = "general"
|
||||
const val CHANNEL_DEBUG = "debug"
|
||||
|
||||
private const val CHANNEL_SYNC = "sync"
|
||||
const val CHANNEL_SYNC_ERRORS = "syncProblems"
|
||||
const val CHANNEL_SYNC_WARNINGS = "syncWarnings"
|
||||
const val CHANNEL_SYNC_IO_ERRORS = "syncIoErrors"
|
||||
|
||||
|
||||
fun createChannels(context: Context) {
|
||||
@TargetApi(Build.VERSION_CODES.O)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
nm.createNotificationChannelGroup(NotificationChannelGroup(CHANNEL_SYNC, context.getString(R.string.notification_channel_sync)))
|
||||
|
||||
nm.createNotificationChannels(listOf(
|
||||
NotificationChannel(CHANNEL_DEBUG, context.getString(R.string.notification_channel_debugging), NotificationManager.IMPORTANCE_HIGH),
|
||||
NotificationChannel(CHANNEL_GENERAL, context.getString(R.string.notification_channel_general), NotificationManager.IMPORTANCE_DEFAULT),
|
||||
|
||||
NotificationChannel(CHANNEL_SYNC_ERRORS, context.getString(R.string.notification_channel_sync_errors), NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||
description = context.getString(R.string.notification_channel_sync_errors_desc)
|
||||
group = CHANNEL_SYNC
|
||||
},
|
||||
NotificationChannel(CHANNEL_SYNC_WARNINGS, context.getString(R.string.notification_channel_sync_warnings), NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = context.getString(R.string.notification_channel_sync_warnings_desc)
|
||||
group = CHANNEL_SYNC
|
||||
},
|
||||
NotificationChannel(CHANNEL_SYNC_IO_ERRORS, context.getString(R.string.notification_channel_sync_io_errors), NotificationManager.IMPORTANCE_MIN).apply {
|
||||
description = context.getString(R.string.notification_channel_sync_io_errors_desc)
|
||||
group = CHANNEL_SYNC
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fun newBuilder(context: Context, channel: String): NotificationCompat.Builder {
|
||||
val builder = NotificationCompat.Builder(context, channel)
|
||||
.setColor(context.resources.getColor(R.color.primaryColor))
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N)
|
||||
builder.setLargeIcon(App.getLauncherBitmap(context))
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.ActivityCompat
|
||||
import android.support.v4.app.NotificationManagerCompat
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.view.View
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import kotlinx.android.synthetic.main.activity_permissions.*
|
||||
|
||||
class PermissionsActivity: AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
@JvmField val PERMISSION_READ_TASKS = "org.dmfs.permission.READ_TASKS"
|
||||
@JvmField val PERMISSION_WRITE_TASKS = "org.dmfs.permission.WRITE_TASKS"
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_permissions)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
val noCalendarPermissions =
|
||||
ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_CALENDAR) != PackageManager.PERMISSION_GRANTED ||
|
||||
ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_CALENDAR) != PackageManager.PERMISSION_GRANTED;
|
||||
calendar_permissions.visibility = if (noCalendarPermissions) View.VISIBLE else View.GONE
|
||||
|
||||
val noContactsPermissions =
|
||||
ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED ||
|
||||
ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_CONTACTS) != PackageManager.PERMISSION_GRANTED;
|
||||
contacts_permissions.visibility = if (noContactsPermissions) View.VISIBLE else View.GONE
|
||||
|
||||
val noTaskPermissions: Boolean
|
||||
if (LocalTaskList.tasksProviderAvailable(this)) {
|
||||
noTaskPermissions =
|
||||
ActivityCompat.checkSelfPermission(this, PERMISSION_READ_TASKS) != PackageManager.PERMISSION_GRANTED ||
|
||||
ActivityCompat.checkSelfPermission(this, PERMISSION_WRITE_TASKS) != PackageManager.PERMISSION_GRANTED
|
||||
findViewById(R.id.opentasks_permissions).visibility = if (noTaskPermissions) View.VISIBLE else View.GONE
|
||||
} else {
|
||||
findViewById(R.id.opentasks_permissions).visibility = View.GONE;
|
||||
noTaskPermissions = false
|
||||
}
|
||||
|
||||
if (!noCalendarPermissions && !noContactsPermissions && !noTaskPermissions) {
|
||||
val nm = NotificationManagerCompat.from(this)
|
||||
nm.cancel(Constants.NOTIFICATION_PERMISSIONS)
|
||||
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
fun requestCalendarPermissions(v: View) {
|
||||
ActivityCompat.requestPermissions(this, arrayOf(
|
||||
Manifest.permission.READ_CALENDAR,
|
||||
Manifest.permission.WRITE_CALENDAR
|
||||
), 0)
|
||||
}
|
||||
|
||||
fun requestContactsPermissions(v: View) {
|
||||
ActivityCompat.requestPermissions(this, arrayOf(
|
||||
Manifest.permission.READ_CONTACTS,
|
||||
Manifest.permission.WRITE_CONTACTS
|
||||
), 0)
|
||||
}
|
||||
|
||||
fun requestOpenTasksPermissions(v: View) {
|
||||
ActivityCompat.requestPermissions(this, arrayOf(
|
||||
PERMISSION_READ_TASKS,
|
||||
PERMISSION_WRITE_TASKS
|
||||
), 0)
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
Loaded 100 of 315 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user