mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2026-08-07 05:44:54 -04:00
Compare commits
747
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4d6453c26 | ||
|
|
9e5632822d | ||
|
|
9538452895 | ||
|
|
9fb73a0dd8 | ||
|
|
6a4a262ba7 | ||
|
|
008f803b14 | ||
|
|
20617072c4 | ||
|
|
bba5942771 | ||
|
|
60a9e174f7 | ||
|
|
d68b626fdd | ||
|
|
47e764067b | ||
|
|
4d54b59a78 | ||
|
|
c312eaa4f8 | ||
|
|
0ed7e2aafd | ||
|
|
119fd6b15c | ||
|
|
bfe5ab002d | ||
|
|
5aad4e477a | ||
|
|
fde0782bb1 | ||
|
|
85c6f43332 | ||
|
|
6d7d825f0c | ||
|
|
c8dca756ee | ||
|
|
01c08dc0a2 | ||
|
|
0d9e2235b2 | ||
|
|
bd0a160a1d | ||
|
|
b136328801 | ||
|
|
f15e2e4827 | ||
|
|
346921d851 | ||
|
|
d4b39e1b16 | ||
|
|
e100d2dd27 | ||
|
|
4328a19ef8 | ||
|
|
a8ea784fcb | ||
|
|
192040cbbb | ||
|
|
3037e003f1 | ||
|
|
70698b4f0e | ||
|
|
13067cc972 | ||
|
|
f6c5478956 | ||
|
|
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
-16
@@ -1,32 +1,20 @@
|
||||
image: registry.gitlab.com/bitfireat/docker-android-emulator: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:
|
||||
- .gradle/
|
||||
|
||||
test:
|
||||
tags:
|
||||
- privileged
|
||||
script:
|
||||
- curl -d "email=gitlab%40bitfire.at&password=$DAVTEST_TOKEN&action=Request+access" -X POST "https://davtest.dev001.net/access/"
|
||||
- start-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
-3
@@ -1,9 +1,12 @@
|
||||
[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,47 +0,0 @@
|
||||
|
||||
Contributing to DAVx⁵
|
||||
=====================
|
||||
|
||||
**Thank you for your interest in contributing to DAVx⁵!**
|
||||
|
||||
Because you're reading this, you're probably interested in
|
||||
contributing to the DAVx⁵ code. [Other ways to contribute:
|
||||
see here.](https://www.davx5.com/donate#c306)
|
||||
|
||||
To contribute:
|
||||
|
||||
1. It's good idea to have a look at the [DAVx⁵ Roadmap](https://gitlab.com/bitfireAT/davx5-ose/wikis/Roadmap)
|
||||
to see whether the change is already planned. Maybe there's even a link to a
|
||||
corresponding forum thread there.
|
||||
1. Determine which project the changes shall go to. There's
|
||||
the DAVx⁵ main project (this repo), and the [related
|
||||
libraries](README.md).
|
||||
1. Please post to the [DAVx⁵ development forum](https://www.davx5.com/forums)
|
||||
before doing actual work (unless you do it only for yourself, of course).
|
||||
This will help to coordinate activities and you'll also get hints
|
||||
about where to start and possible pitfalls.
|
||||
1. Fork the repository.
|
||||
1. Do the changes in your repository.
|
||||
1. Submit a pull request to the original project.
|
||||
1. Post in the forum again (to make sure the pull request is being notified).
|
||||
|
||||
|
||||
Questions, discussion
|
||||
=====================
|
||||
|
||||
We're happy to see questions, discussions etc. in the
|
||||
[DAVx⁵ development forum](https://www.davx5.com/forums)!
|
||||
|
||||
|
||||
Licensing
|
||||
=========
|
||||
|
||||
All code has to be licensed under the GPL.
|
||||
|
||||
We (bitfire.at, initial developers) are also asking you to double-license the
|
||||
code so that we can also use it for related non-open source projects like
|
||||
[Managed DAVx⁵](https://www.davx5.com/organizations/managed-davx5).
|
||||
|
||||
Please find more about this in the Contributor's License Agreement (CLA)
|
||||
we'll send to you if you want to contribute.
|
||||
|
||||
@@ -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)
|
||||
+56
-127
@@ -7,169 +7,98 @@
|
||||
*/
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'com.mikepenz.aboutlibraries.plugin'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'org.jetbrains.dokka'
|
||||
apply plugin: 'kotlin-android-extensions'
|
||||
|
||||
android {
|
||||
compileSdkVersion 30
|
||||
buildToolsVersion '30.0.3'
|
||||
compileSdkVersion 26
|
||||
buildToolsVersion '26.0.1'
|
||||
|
||||
defaultConfig {
|
||||
applicationId "at.bitfire.davdroid"
|
||||
|
||||
versionCode 303120002
|
||||
minSdkVersion 16
|
||||
targetSdkVersion 25
|
||||
|
||||
versionCode 172
|
||||
|
||||
buildConfigField "long", "buildTime", System.currentTimeMillis() + "L"
|
||||
|
||||
minSdkVersion 21 // Android 5
|
||||
targetSdkVersion 30 // Android 11
|
||||
|
||||
buildConfigField "String", "userAgent", "\"DAVx5\""
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
kapt {
|
||||
arguments {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
}
|
||||
buildConfigField "boolean", "customCerts", "true"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
// enable because ical4android requires desugaring
|
||||
coreLibraryDesugaringEnabled true
|
||||
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
dataBinding = true
|
||||
}
|
||||
|
||||
flavorDimensions "distribution"
|
||||
productFlavors {
|
||||
standard {
|
||||
versionName "3.3.12-beta3-ose"
|
||||
versionName "1.8.1-ose"
|
||||
buildConfigField "boolean", "customCerts", "true"
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
minifyEnabled false
|
||||
}
|
||||
release {
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules-release.pro'
|
||||
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
|
||||
}
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
disable 'GoogleAppIndexingWarning' // we don't need Google indexing, thanks
|
||||
disable 'ImpliedQuantity', 'MissingQuantity' // quantities from Transifex may vary
|
||||
disable 'MissingTranslation', 'ExtraTranslation' // translations from Transifex are not always up to date
|
||||
disable 'RtlEnabled', 'RtlHardcoded' // RTL not supported yet
|
||||
disable 'GoogleAppIndexingWarning' // we don't need Google indexing, thanks
|
||||
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'
|
||||
disable 'NullSafeMutableLiveData' // crashes lint since Mar 2021
|
||||
}
|
||||
packagingOptions {
|
||||
exclude 'META-INF/DEPENDENCIES'
|
||||
exclude 'META-INF/LICENSE'
|
||||
}
|
||||
|
||||
dokka.configuration {
|
||||
sourceLink {
|
||||
url = "https://gitlab.com/bitfireAT/davx5-ose/tree/master-ose/"
|
||||
lineSuffix = "#L"
|
||||
}
|
||||
jdkVersion = 8
|
||||
|
||||
externalDocumentationLink {
|
||||
url = new URL("https://bitfireat.gitlab.io/cert4android/dokka/cert4android/")
|
||||
packageListUrl = new URL("https://bitfireat.gitlab.io/cert4android/dokka/cert4android/package-list")
|
||||
}
|
||||
externalDocumentationLink {
|
||||
url = new URL("https://bitfireat.gitlab.io/dav4jvm/dokka/dav4jvm/")
|
||||
packageListUrl = new URL("https://bitfireat.gitlab.io/dav4jvm/dokka/dav4jvm/package-list")
|
||||
}
|
||||
externalDocumentationLink {
|
||||
url = new URL("https://bitfireat.gitlab.io/ical4android/dokka/ical4android/")
|
||||
packageListUrl = new URL("https://bitfireat.gitlab.io/ical4android/dokka/ical4android/package-list")
|
||||
}
|
||||
externalDocumentationLink {
|
||||
url = new URL("https://bitfireat.gitlab.io/vcard4android/dokka/vcard4android/")
|
||||
packageListUrl = new URL("https://bitfireat.gitlab.io/vcard4android/dokka/vcard4android/package-list")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
additionalLicenses {
|
||||
mpl_2_0
|
||||
defaultConfig {
|
||||
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 "org.jetbrains.kotlin:kotlin-stdlib-jdk7"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0"
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
|
||||
compile "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version"
|
||||
|
||||
implementation 'androidx.appcompat:appcompat:1.3.0'
|
||||
implementation 'androidx.browser:browser:1.3.0'
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
|
||||
implementation 'androidx.core:core-ktx:1.6.0'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.3.5'
|
||||
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'
|
||||
implementation 'androidx.paging:paging-runtime-ktx:3.0.0'
|
||||
implementation 'androidx.preference:preference-ktx:1.1.1'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'com.google.android:flexbox:2.0.1'
|
||||
implementation 'com.google.android.material:material:1.4.0'
|
||||
compile 'com.android.support:appcompat-v7:26.0.1'
|
||||
compile 'com.android.support:cardview-v7:26.0.1'
|
||||
compile 'com.android.support:design:26.0.1'
|
||||
compile 'com.android.support:preference-v14:26.0.1'
|
||||
|
||||
def room_version = '2.3.0'
|
||||
implementation "androidx.room:room-runtime:$room_version"
|
||||
implementation "androidx.room:room-ktx:$room_version"
|
||||
kapt "androidx.room:room-compiler:$room_version"
|
||||
androidTestImplementation "androidx.room:room-testing:$room_version"
|
||||
compile 'com.github.yukuku:ambilwarna:2.0.1'
|
||||
|
||||
implementation 'com.jaredrummler:colorpicker:1.1.0'
|
||||
implementation "com.github.AppIntro:AppIntro:${versions.appIntro}"
|
||||
implementation "com.gitlab.bitfireAT:dav4jvm:${versions.dav4jvm}"
|
||||
implementation "com.mikepenz:aboutlibraries:${versions.aboutLibraries}"
|
||||
implementation "com.squareup.okhttp3:okhttp:${versions.okhttp}"
|
||||
implementation "com.squareup.okhttp3:okhttp-brotli:${versions.okhttp}"
|
||||
implementation "com.squareup.okhttp3:logging-interceptor:${versions.okhttp}"
|
||||
implementation 'commons-io:commons-io:2.6'
|
||||
//noinspection GradleDependency - dnsjava 3+ needs Java 8/Android 7
|
||||
implementation 'dnsjava:dnsjava:2.1.9'
|
||||
//noinspection GradleDependency
|
||||
implementation "org.apache.commons:commons-collections4:${versions.commonsCollections}"
|
||||
//noinspection GradleDependency
|
||||
implementation "org.apache.commons:commons-lang3:${versions.commonsLang}"
|
||||
//noinspection GradleDependency
|
||||
implementation "org.apache.commons:commons-text:${versions.commonsText}"
|
||||
compile 'com.squareup.okhttp3:logging-interceptor:3.9.0'
|
||||
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.4.0'
|
||||
androidTestImplementation 'androidx.test:rules:1.4.0'
|
||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0'
|
||||
androidTestImplementation 'junit:junit:4.13.2'
|
||||
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.9.0'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}"
|
||||
testCompile 'junit:junit:4.12'
|
||||
testCompile 'com.squareup.okhttp3:mockwebserver:3.9.0'
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
|
||||
# R8 usage for DAVx⁵:
|
||||
# shrinking yes (only in release builds)
|
||||
# optimization yes (on by R8 defaults)
|
||||
# obfuscation no (open-source)
|
||||
|
||||
-dontobfuscate
|
||||
-printusage build/reports/r8-usage.txt
|
||||
|
||||
# ez-vcard: keep all vCard properties/parameters (used via reflection)
|
||||
-keep class ezvcard.io.scribe.** { *; }
|
||||
-keep class ezvcard.property.** { *; }
|
||||
-keep class ezvcard.parameter.** { *; }
|
||||
|
||||
# ical4j: keep all iCalendar properties/parameters (used via reflection)
|
||||
-keep class net.fortuna.ical4j.** { *; }
|
||||
|
||||
# DAVx⁵ + libs
|
||||
-keep class at.bitfire.** { *; } # all DAVx⁵ code is required
|
||||
|
||||
# we use enum classes (https://www.guardsquare.com/en/products/proguard/manual/examples#enumerations)
|
||||
-keepclassmembers,allowoptimization enum * {
|
||||
public static **[] values();
|
||||
public static ** valueOf(java.lang.String);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
|
||||
# ProGuard usage for DAVdroid:
|
||||
# shrinking yes (main reason for using ProGuard)
|
||||
# optimization yes
|
||||
# obfuscation no (DAVdroid is open-source)
|
||||
# preverification no
|
||||
|
||||
-dontobfuscate
|
||||
|
||||
-optimizations !code/simplification/arithmetic,!code/simplification/cast,!field/*,!class/merging/*
|
||||
-optimizationpasses 5
|
||||
-allowaccessmodification
|
||||
-dontpreverify
|
||||
|
||||
# Kotlin
|
||||
-dontwarn kotlin.**
|
||||
|
||||
# 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)
|
||||
|
||||
# ical4j: ignore unused dynamic libraries
|
||||
-dontwarn aQute.**
|
||||
-dontwarn groovy.** # Groovy-based ContentBuilder not used
|
||||
-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 okio.**
|
||||
-dontwarn javax.annotation.Nullable
|
||||
-dontwarn javax.annotation.ParametersAreNonnullByDefault
|
||||
|
||||
# dnsjava
|
||||
-dontwarn sun.net.spi.nameservice.** # not available on Android
|
||||
|
||||
# DAVdroid + libs
|
||||
-keep class at.bitfire.** { *; } # all DAVdroid code is required
|
||||
@@ -1,298 +0,0 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 8,
|
||||
"identityHash": "b8699ef3cc4c62e8851df4360fb69e00",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "service",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountName",
|
||||
"columnName": "accountName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "principal",
|
||||
"columnName": "principal",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_service_accountName_type",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"accountName",
|
||||
"type"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "homeset",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "serviceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "personal",
|
||||
"columnName": "personal",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "privBind",
|
||||
"columnName": "privBind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_homeset_serviceId_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"serviceId",
|
||||
"url"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "service",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"serviceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "collection",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `owner` TEXT, `color` INTEGER, `timezone` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "serviceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "homeSetId",
|
||||
"columnName": "homeSetId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "privWriteContent",
|
||||
"columnName": "privWriteContent",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "privUnbind",
|
||||
"columnName": "privUnbind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "forceReadOnly",
|
||||
"columnName": "forceReadOnly",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "owner",
|
||||
"columnName": "owner",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "color",
|
||||
"columnName": "color",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "timezone",
|
||||
"columnName": "timezone",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsVEVENT",
|
||||
"columnName": "supportsVEVENT",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsVTODO",
|
||||
"columnName": "supportsVTODO",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsVJOURNAL",
|
||||
"columnName": "supportsVJOURNAL",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "source",
|
||||
"columnName": "source",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "sync",
|
||||
"columnName": "sync",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_collection_serviceId_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"serviceId",
|
||||
"type"
|
||||
],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_homeSetId_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"homeSetId",
|
||||
"type"
|
||||
],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "service",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"serviceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "homeset",
|
||||
"onDelete": "SET NULL",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"homeSetId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b8699ef3cc4c62e8851df4360fb69e00')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 9,
|
||||
"identityHash": "7e4bfdf7f9fa3529c333cf9485f8cf50",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "service",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `principal` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accountName",
|
||||
"columnName": "accountName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "principal",
|
||||
"columnName": "principal",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_service_accountName_type",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"accountName",
|
||||
"type"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_service_accountName_type` ON `${TABLE_NAME}` (`accountName`, `type`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "homeset",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `personal` INTEGER NOT NULL, `url` TEXT NOT NULL, `privBind` INTEGER NOT NULL, `displayName` TEXT, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "serviceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "personal",
|
||||
"columnName": "personal",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "privBind",
|
||||
"columnName": "privBind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_homeset_serviceId_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"serviceId",
|
||||
"url"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_homeset_serviceId_url` ON `${TABLE_NAME}` (`serviceId`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "service",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"serviceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "collection",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `serviceId` INTEGER NOT NULL, `homeSetId` INTEGER, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `privWriteContent` INTEGER NOT NULL, `privUnbind` INTEGER NOT NULL, `forceReadOnly` INTEGER NOT NULL, `displayName` TEXT, `description` TEXT, `owner` TEXT, `color` INTEGER, `timezone` TEXT, `supportsVEVENT` INTEGER, `supportsVTODO` INTEGER, `supportsVJOURNAL` INTEGER, `source` TEXT, `sync` INTEGER NOT NULL, FOREIGN KEY(`serviceId`) REFERENCES `service`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`homeSetId`) REFERENCES `homeset`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "serviceId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "homeSetId",
|
||||
"columnName": "homeSetId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "privWriteContent",
|
||||
"columnName": "privWriteContent",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "privUnbind",
|
||||
"columnName": "privUnbind",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "forceReadOnly",
|
||||
"columnName": "forceReadOnly",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayName",
|
||||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "owner",
|
||||
"columnName": "owner",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "color",
|
||||
"columnName": "color",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "timezone",
|
||||
"columnName": "timezone",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsVEVENT",
|
||||
"columnName": "supportsVEVENT",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsVTODO",
|
||||
"columnName": "supportsVTODO",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "supportsVJOURNAL",
|
||||
"columnName": "supportsVJOURNAL",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "source",
|
||||
"columnName": "source",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "sync",
|
||||
"columnName": "sync",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_collection_serviceId_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"serviceId",
|
||||
"type"
|
||||
],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_serviceId_type` ON `${TABLE_NAME}` (`serviceId`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_homeSetId_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"homeSetId",
|
||||
"type"
|
||||
],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_homeSetId_type` ON `${TABLE_NAME}` (`homeSetId`, `type`)"
|
||||
},
|
||||
{
|
||||
"name": "index_collection_url",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"url"
|
||||
],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collection_url` ON `${TABLE_NAME}` (`url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "service",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"serviceId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "homeset",
|
||||
"onDelete": "SET NULL",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"homeSetId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "syncstats",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collectionId` INTEGER NOT NULL, `authority` TEXT NOT NULL, `lastSync` INTEGER NOT NULL, FOREIGN KEY(`collectionId`) REFERENCES `collection`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "collectionId",
|
||||
"columnName": "collectionId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "authority",
|
||||
"columnName": "authority",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastSync",
|
||||
"columnName": "lastSync",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_syncstats_collectionId_authority",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"collectionId",
|
||||
"authority"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_syncstats_collectionId_authority` ON `${TABLE_NAME}` (`collectionId`, `authority`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "collection",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"collectionId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7e4bfdf7f9fa3529c333cf9485f8cf50')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
espressoTest
|
||||
@@ -1,28 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="at.bitfire.davdroid"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:installLocation="internalOnly">
|
||||
|
||||
<!-- account management permissions not required for own accounts since API level 22 -->
|
||||
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
<uses-permission android:name="android.permission.GET_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" android:maxSdkVersion="22"/>
|
||||
|
||||
<application>
|
||||
|
||||
<service
|
||||
android:name=".syncadapter.NullAuthenticatorService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.AccountAuthenticator"/>
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.accounts.AccountAuthenticator"
|
||||
android:resource="@xml/test_account_authenticator"/>
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -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,63 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import 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 javax.net.ssl.TrustManagerFactory;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
|
||||
import at.bitfire.cert4android.CustomCertManager;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
|
||||
import static android.support.test.InstrumentationRegistry.getInstrumentation;
|
||||
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 {
|
||||
|
||||
CustomCertManager certMgr;
|
||||
SSLSocketFactoryCompat factory;
|
||||
MockWebServer server = new MockWebServer();
|
||||
|
||||
@Before
|
||||
public void startServer() throws Exception {
|
||||
certMgr = new CustomCertManager(getInstrumentation().getContext(), true, null);
|
||||
factory = new SSLSocketFactoryCompat(certMgr);
|
||||
server.start();
|
||||
}
|
||||
|
||||
@After
|
||||
public void stopServer() throws Exception {
|
||||
server.shutdown();
|
||||
certMgr.close();
|
||||
}
|
||||
|
||||
|
||||
@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"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class AppDatabaseTest {
|
||||
|
||||
val TEST_DB = "test"
|
||||
|
||||
@Rule
|
||||
@JvmField
|
||||
val helper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
AppDatabase::class.java.canonicalName,
|
||||
FrameworkSQLiteOpenHelperFactory()
|
||||
)
|
||||
|
||||
|
||||
@Test
|
||||
fun testAllMigrations() {
|
||||
// DB schema is available since version 8
|
||||
helper.createDatabase(TEST_DB, 8).close()
|
||||
|
||||
Room.databaseBuilder(
|
||||
InstrumentationRegistry.getInstrumentation().targetContext,
|
||||
AppDatabase::class.java,
|
||||
TEST_DB
|
||||
).addMigrations(*AppDatabase.migrations).build().apply {
|
||||
openHelper.writableDatabase
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* 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.After;
|
||||
import org.junit.Before;
|
||||
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 {
|
||||
|
||||
HttpClient httpClient;
|
||||
MockWebServer server = new MockWebServer();
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
httpClient = new HttpClient.Builder().build();
|
||||
}
|
||||
|
||||
@After
|
||||
public void shutDown() {
|
||||
httpClient.close();
|
||||
}
|
||||
|
||||
|
||||
@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.getOkHttpClient(), 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.getOkHttpClient(), 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.TYPE, CollectionInfo.Type.CALENDAR.name());
|
||||
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(CollectionInfo.Type.CALENDAR, info.getType());
|
||||
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,134 +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 androidx.test.filters.SmallTest
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.property.ResourceType
|
||||
import at.bitfire.davdroid.HttpClient
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
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("https://example.com/1.ics".toHttpUrl(), info.source)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
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, personal=true, url= "https://example.com/1".toHttpUrl())
|
||||
val entry3 = HomeSet(id=3, serviceId=service.id, personal=true, url= "https://example.com/3".toHttpUrl())
|
||||
val oldItems = listOf(
|
||||
entry1,
|
||||
HomeSet(id=2, serviceId=service.id, personal=true, url= "https://example.com/2".toHttpUrl()),
|
||||
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, personal=true,
|
||||
url= "https://example.com/2".toHttpUrl(), displayName="Updated Entry")
|
||||
newItems[updated.url] = updated
|
||||
|
||||
val created = HomeSet(id=4, serviceId=service.id, personal=true, url= "https://example.com/4".toHttpUrl())
|
||||
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,72 +0,0 @@
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentResolver
|
||||
import android.os.Build
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.model.Credentials
|
||||
import at.bitfire.davdroid.syncadapter.AccountUtils
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class AccountSettingsTest {
|
||||
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
val account = Account("Test Account", context.getString(R.string.account_type))
|
||||
val fakeCredentials = Credentials("test", "test")
|
||||
|
||||
|
||||
@Before
|
||||
fun prepareAccount() {
|
||||
assertTrue(AccountUtils.createAccount(context, account, AccountSettings.initialUserData(fakeCredentials)))
|
||||
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
|
||||
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0)
|
||||
}
|
||||
|
||||
@After
|
||||
fun removeAccount() {
|
||||
val futureResult = AccountManager.get(context).removeAccount(account, {}, null)
|
||||
assertTrue(futureResult.getResult(10, TimeUnit.SECONDS))
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testSyncIntervals() {
|
||||
val settings = AccountSettings(context, account)
|
||||
val presetIntervals = context.resources.getStringArray(R.array.settings_sync_interval_seconds)
|
||||
.map { it.toLong() }
|
||||
.filter { it != AccountSettings.SYNC_INTERVAL_MANUALLY }
|
||||
for (interval in presetIntervals) {
|
||||
assertTrue(settings.setSyncInterval(CalendarContract.AUTHORITY, interval))
|
||||
assertEquals(interval, settings.getSyncInterval(CalendarContract.AUTHORITY))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSyncIntervals_IsNotSyncable() {
|
||||
val settings = AccountSettings(context, account)
|
||||
val interval = 15*60L // 15 min
|
||||
val result = settings.setSyncInterval(ContactsContract.AUTHORITY, interval)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) // below Android 7, Android returns true for whatever reason
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSyncIntervals_TooShort() {
|
||||
val settings = AccountSettings(context, account)
|
||||
val interval = 60L // 1 min is not supported by Android
|
||||
val result = settings.setSyncInterval(CalendarContract.AUTHORITY, interval)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) // below Android 7, Android returns true for whatever reason
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,28 +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.Test
|
||||
|
||||
class SettingsManagerTest {
|
||||
|
||||
val settingsManager by lazy { SettingsManager.getInstance(InstrumentationRegistry.getInstrumentation().targetContext) }
|
||||
|
||||
@Test
|
||||
fun testContainsKey() {
|
||||
assertFalse(settingsManager.containsKey("notExisting"))
|
||||
|
||||
// provided by DefaultsProvider
|
||||
assertTrue(settingsManager.containsKey(Settings.OVERRIDE_PROXY))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.os.Bundle
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.R
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class AccountUtilsTest {
|
||||
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val account = Account("Test Account", context.getString(R.string.account_type))
|
||||
|
||||
@Test
|
||||
fun testCreateAccount() {
|
||||
val userData = Bundle(2)
|
||||
userData.putString("int", "1")
|
||||
userData.putString("string", "abc/\"-")
|
||||
try {
|
||||
assertTrue(AccountUtils.createAccount(context, account, userData))
|
||||
|
||||
// validate user data
|
||||
val manager = AccountManager.get(context)
|
||||
assertEquals("1", manager.getUserData(account, "int"))
|
||||
assertEquals("abc/\"-", manager.getUserData(account, "string"))
|
||||
} finally {
|
||||
val futureResult = AccountManager.get(context).removeAccount(account, {}, null)
|
||||
assertTrue(futureResult.getResult(10, TimeUnit.SECONDS))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.davdroid.resource.LocalCollection
|
||||
|
||||
class LocalTestCollection: LocalCollection<LocalTestResource> {
|
||||
|
||||
override val tag = "LocalTestCollection"
|
||||
override val title = "Local Test Collection"
|
||||
|
||||
override var lastSyncState: SyncState? = null
|
||||
|
||||
val entries = mutableListOf<LocalTestResource>()
|
||||
|
||||
override fun findDeleted() = entries.filter { it.deleted }
|
||||
override fun findDirty() = entries.filter { it.dirty }
|
||||
|
||||
override fun findByName(name: String) = entries.filter { it.fileName == name }.firstOrNull()
|
||||
|
||||
override fun markNotDirty(flags: Int): Int {
|
||||
var updated = 0
|
||||
for (dirty in findDirty()) {
|
||||
dirty.flags = flags
|
||||
updated++
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int): Int {
|
||||
val numBefore = entries.size
|
||||
entries.removeIf { !it.dirty && it.flags == flags }
|
||||
return numBefore - entries.size
|
||||
}
|
||||
|
||||
override fun forgetETags() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.net.Uri
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
|
||||
class LocalTestResource: LocalResource<Any> {
|
||||
|
||||
override val id: Long? = null
|
||||
override var fileName: String? = null
|
||||
override var eTag: String? = null
|
||||
override var scheduleTag: String? = null
|
||||
override var flags: Int = 0
|
||||
|
||||
var deleted = false
|
||||
var dirty = false
|
||||
|
||||
override fun prepareForUpload() = "generated-file.txt"
|
||||
|
||||
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
|
||||
dirty = false
|
||||
if (fileName != null)
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.scheduleTag = scheduleTag
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
override fun add(): Uri {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun update(data: Any): Uri {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun delete(): Int {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.test.filters.SmallTest
|
||||
import at.bitfire.davdroid.syncadapter.SyncAdapterService.SyncAdapter
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SyncAdapterServiceTest {
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testPriorityCollections() {
|
||||
val extras = Bundle()
|
||||
assertTrue(SyncAdapter.priorityCollections(extras).isEmpty())
|
||||
|
||||
extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, "")
|
||||
assertTrue(SyncAdapter.priorityCollections(extras).isEmpty())
|
||||
|
||||
extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, "123")
|
||||
assertArrayEquals(longArrayOf(123), SyncAdapter.priorityCollections(extras).toLongArray())
|
||||
|
||||
extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, ",x,")
|
||||
assertTrue(SyncAdapter.priorityCollections(extras).isEmpty())
|
||||
|
||||
extras.putString(SyncAdapterService.SYNC_EXTRAS_PRIORITY_COLLECTIONS, "1,2,3")
|
||||
assertArrayEquals(longArrayOf(1,2,3), SyncAdapter.priorityCollections(extras).toLongArray())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,437 +0,0 @@
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.dav4jvm.PropStat
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.Response.HrefRelation
|
||||
import at.bitfire.dav4jvm.property.GetETag
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.model.Credentials
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.internal.http.StatusLine
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.*
|
||||
import org.junit.Assert.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class SyncManagerTest {
|
||||
|
||||
companion object {
|
||||
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val account = Account("SyncManagerTest", context.getString(R.string.account_type))
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun createAccount() {
|
||||
assertTrue(AccountManager.get(context).addAccountExplicitly(account, "test", AccountSettings.initialUserData(Credentials("test", "test"))))
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun removeAccount() {
|
||||
assertTrue(AccountManager.get(context).removeAccount(account, null, null).getResult(10, TimeUnit.SECONDS))
|
||||
|
||||
// clear annoying syncError notifications
|
||||
NotificationManagerCompat.from(context).cancelAll()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
val server = MockWebServer()
|
||||
|
||||
private fun syncManager(collection: LocalTestCollection) =
|
||||
TestSyncManager(
|
||||
context,
|
||||
account,
|
||||
Bundle(),
|
||||
"TestAuthority",
|
||||
SyncResult(),
|
||||
collection,
|
||||
server
|
||||
)
|
||||
|
||||
@Before
|
||||
fun startServer() {
|
||||
server.start()
|
||||
}
|
||||
|
||||
@After
|
||||
fun stopServer() {
|
||||
server.close()
|
||||
}
|
||||
|
||||
|
||||
private fun queryCapabilitiesResponse(cTag: String? = null): MockResponse {
|
||||
val body = StringBuilder()
|
||||
body.append("<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" +
|
||||
"<multistatus xmlns=\"DAV:\" xmlns:CALDAV=\"http://calendarserver.org/ns/\">\n" +
|
||||
" <response>\n" +
|
||||
" <href>/</href>\n" +
|
||||
" <propstat>\n" +
|
||||
" <prop>\n")
|
||||
if (cTag != null)
|
||||
body.append("<CALDAV:getctag>$cTag</CALDAV:getctag>\n")
|
||||
body.append(
|
||||
" </prop>\n" +
|
||||
" </propstat>\n" +
|
||||
" </response>\n" +
|
||||
"</multistatus>")
|
||||
val response = MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "text/xml")
|
||||
.setBody(body.toString())
|
||||
return response
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_FirstSync_Empty() {
|
||||
val collection = LocalTestCollection() /* no last known ctag */
|
||||
server.enqueue(queryCapabilitiesResponse())
|
||||
|
||||
val syncManager = syncManager(collection)
|
||||
syncManager.performSync()
|
||||
|
||||
assertFalse(syncManager.didGenerateUpload)
|
||||
assertTrue(syncManager.didListAllRemote)
|
||||
assertFalse(syncManager.didDownloadRemote)
|
||||
assertFalse(syncManager.syncResult.hasError())
|
||||
assertTrue(collection.entries.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_UploadNewMember_ETagOnPut() {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
entries += LocalTestResource().apply {
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
server.enqueue(queryCapabilitiesResponse("ctag1"))
|
||||
|
||||
// PUT -> 204 No Content
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(204)
|
||||
.setHeader("ETag", "etag-from-put"))
|
||||
|
||||
// modifications sent, so DAVx5 will query CTag again
|
||||
server.enqueue(queryCapabilitiesResponse("ctag2"))
|
||||
|
||||
val syncManager = syncManager(collection).apply {
|
||||
listAllRemoteResult = listOf(
|
||||
Pair(Response(
|
||||
server.url("/"),
|
||||
server.url("/generated-file.txt"),
|
||||
null,
|
||||
listOf(PropStat(
|
||||
listOf(
|
||||
GetETag("\"etag-from-put\"")
|
||||
),
|
||||
StatusLine(Protocol.HTTP_1_1, 200, "OK")
|
||||
)
|
||||
)), HrefRelation.MEMBER)
|
||||
)
|
||||
}
|
||||
syncManager.performSync()
|
||||
|
||||
assertTrue(syncManager.didGenerateUpload)
|
||||
assertTrue(syncManager.didListAllRemote)
|
||||
assertFalse(syncManager.didDownloadRemote)
|
||||
assertFalse(syncManager.syncResult.hasError())
|
||||
assertEquals(1, collection.entries.size)
|
||||
assertEquals("etag-from-put", collection.entries.first().eTag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_UploadModifiedMember_ETagOnPut() {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
entries += LocalTestResource().apply {
|
||||
fileName = "existing-file.txt"
|
||||
eTag = "old-etag-like-on-server"
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
server.enqueue(queryCapabilitiesResponse("ctag1"))
|
||||
|
||||
// PUT -> 204 No Content
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(204)
|
||||
.addHeader("ETag", "etag-from-put"))
|
||||
|
||||
// modifications sent, so DAVx5 will query CTag again
|
||||
server.enqueue(queryCapabilitiesResponse("ctag2"))
|
||||
|
||||
val syncManager = syncManager(collection).apply {
|
||||
listAllRemoteResult = listOf(
|
||||
Pair(Response(
|
||||
server.url("/"),
|
||||
server.url("/existing-file.txt"),
|
||||
null,
|
||||
listOf(PropStat(
|
||||
listOf(
|
||||
GetETag("etag-from-put")
|
||||
),
|
||||
StatusLine(Protocol.HTTP_1_1, 200, "OK")
|
||||
)
|
||||
)), HrefRelation.MEMBER)
|
||||
)
|
||||
|
||||
assertDownloadRemote = mapOf(Pair(server.url("/existing-file.txt"), "etag-from-put"))
|
||||
}
|
||||
syncManager.performSync()
|
||||
|
||||
assertTrue(syncManager.didGenerateUpload)
|
||||
assertTrue(syncManager.didListAllRemote)
|
||||
assertFalse(syncManager.didDownloadRemote)
|
||||
assertFalse(syncManager.syncResult.hasError())
|
||||
assertEquals(1, collection.entries.size)
|
||||
assertEquals("etag-from-put", collection.entries.first().eTag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_UploadModifiedMember_NoETagOnPut() {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
entries += LocalTestResource().apply {
|
||||
fileName = "existing-file.txt"
|
||||
eTag = "old-etag-like-on-server"
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
server.enqueue(queryCapabilitiesResponse("ctag1"))
|
||||
|
||||
// PUT -> 204 No Content
|
||||
server.enqueue(MockResponse().setResponseCode(204))
|
||||
|
||||
// modifications sent, so DAVx5 will query CTag again
|
||||
server.enqueue(queryCapabilitiesResponse("ctag2"))
|
||||
|
||||
val syncManager = syncManager(collection).apply {
|
||||
listAllRemoteResult = listOf(
|
||||
Pair(Response(
|
||||
server.url("/"),
|
||||
server.url("/existing-file.txt"),
|
||||
null,
|
||||
listOf(PropStat(
|
||||
listOf(
|
||||
GetETag("etag-from-propfind")
|
||||
),
|
||||
StatusLine(Protocol.HTTP_1_1, 200, "OK")
|
||||
)
|
||||
)), HrefRelation.MEMBER)
|
||||
)
|
||||
|
||||
assertDownloadRemote = mapOf(Pair(server.url("/existing-file.txt"), "etag-from-propfind"))
|
||||
}
|
||||
syncManager.performSync()
|
||||
|
||||
assertTrue(syncManager.didGenerateUpload)
|
||||
assertTrue(syncManager.didListAllRemote)
|
||||
assertTrue(syncManager.didDownloadRemote)
|
||||
assertFalse(syncManager.syncResult.hasError())
|
||||
assertEquals(1, collection.entries.size)
|
||||
assertEquals("etag-from-propfind", collection.entries.first().eTag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_UploadModifiedMember_412PreconditionFailed() {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
entries += LocalTestResource().apply {
|
||||
fileName = "existing-file.txt"
|
||||
eTag = "etag-that-has-been-changed-on-server-in-the-meanwhile"
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
server.enqueue(queryCapabilitiesResponse("ctag1"))
|
||||
|
||||
// PUT -> 412 Precondition Failed
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(412))
|
||||
|
||||
// modifications sent, so DAVx5 will query CTag again
|
||||
server.enqueue(queryCapabilitiesResponse("ctag1"))
|
||||
|
||||
val syncManager = syncManager(collection).apply {
|
||||
listAllRemoteResult = listOf(
|
||||
Pair(Response(
|
||||
server.url("/"),
|
||||
server.url("/existing-file.txt"),
|
||||
null,
|
||||
listOf(PropStat(
|
||||
listOf(
|
||||
GetETag("changed-etag-from-server")
|
||||
),
|
||||
StatusLine(Protocol.HTTP_1_1, 200, "OK")
|
||||
)
|
||||
)), HrefRelation.MEMBER)
|
||||
)
|
||||
|
||||
assertDownloadRemote = mapOf(Pair(server.url("/existing-file.txt"), "changed-etag-from-server"))
|
||||
}
|
||||
syncManager.performSync()
|
||||
|
||||
assertTrue(syncManager.didGenerateUpload)
|
||||
assertTrue(syncManager.didListAllRemote)
|
||||
assertTrue(syncManager.didDownloadRemote)
|
||||
assertFalse(syncManager.syncResult.hasError())
|
||||
assertEquals(1, collection.entries.size)
|
||||
assertEquals("changed-etag-from-server", collection.entries.first().eTag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_NoopOnMemberWithSameETag() {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "ctag1")
|
||||
entries += LocalTestResource().apply {
|
||||
fileName = "downloaded-member.txt"
|
||||
eTag = "MemberETag1"
|
||||
}
|
||||
}
|
||||
server.enqueue(queryCapabilitiesResponse("ctag2"))
|
||||
|
||||
val syncManager = syncManager(collection).apply {
|
||||
listAllRemoteResult = listOf(
|
||||
Pair(Response(
|
||||
server.url("/"),
|
||||
server.url("/downloaded-member.txt"),
|
||||
null,
|
||||
listOf(PropStat(
|
||||
listOf(
|
||||
GetETag("\"MemberETag1\"")
|
||||
),
|
||||
StatusLine(Protocol.HTTP_1_1, 200, "OK")
|
||||
)
|
||||
)), HrefRelation.MEMBER)
|
||||
)
|
||||
|
||||
}
|
||||
syncManager.performSync()
|
||||
|
||||
assertFalse(syncManager.didGenerateUpload)
|
||||
assertTrue(syncManager.didListAllRemote)
|
||||
assertFalse(syncManager.didDownloadRemote)
|
||||
assertFalse(syncManager.syncResult.hasError())
|
||||
assertEquals(1, collection.entries.size)
|
||||
assertEquals("MemberETag1", collection.entries.first().eTag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_DownloadNewMember() {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
}
|
||||
server.enqueue(queryCapabilitiesResponse(cTag = "new-ctag"))
|
||||
|
||||
val syncManager = syncManager(collection).apply {
|
||||
listAllRemoteResult = listOf(
|
||||
Pair(Response(
|
||||
server.url("/"),
|
||||
server.url("/new-member.txt"),
|
||||
null,
|
||||
listOf(PropStat(
|
||||
listOf(
|
||||
GetETag("\"NewMemberETag1\"")
|
||||
),
|
||||
StatusLine(Protocol.HTTP_1_1, 200, "OK")
|
||||
)
|
||||
)), HrefRelation.MEMBER)
|
||||
)
|
||||
|
||||
assertDownloadRemote = mapOf(Pair(server.url("/new-member.txt"), "NewMemberETag1"))
|
||||
}
|
||||
syncManager.performSync()
|
||||
|
||||
assertFalse(syncManager.didGenerateUpload)
|
||||
assertTrue(syncManager.didListAllRemote)
|
||||
assertTrue(syncManager.didDownloadRemote)
|
||||
assertFalse(syncManager.syncResult.hasError())
|
||||
assertEquals(1, collection.entries.size)
|
||||
assertEquals("NewMemberETag1", collection.entries.first().eTag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_DownloadUpdatedMember() {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
entries += LocalTestResource().apply {
|
||||
fileName = "downloaded-member.txt"
|
||||
eTag = "MemberETag1"
|
||||
}
|
||||
}
|
||||
server.enqueue(queryCapabilitiesResponse(cTag = "new-ctag"))
|
||||
|
||||
val syncManager = syncManager(collection).apply {
|
||||
listAllRemoteResult = listOf(
|
||||
Pair(Response(
|
||||
server.url("/"),
|
||||
server.url("/downloaded-member.txt"),
|
||||
null,
|
||||
listOf(PropStat(
|
||||
listOf(
|
||||
GetETag("\"MemberETag2\"")
|
||||
),
|
||||
StatusLine(Protocol.HTTP_1_1, 200, "OK")
|
||||
)
|
||||
)), HrefRelation.MEMBER)
|
||||
)
|
||||
|
||||
assertDownloadRemote = mapOf(Pair(server.url("/downloaded-member.txt"), "MemberETag2"))
|
||||
}
|
||||
syncManager.performSync()
|
||||
|
||||
assertFalse(syncManager.didGenerateUpload)
|
||||
assertTrue(syncManager.didListAllRemote)
|
||||
assertTrue(syncManager.didDownloadRemote)
|
||||
assertFalse(syncManager.syncResult.hasError())
|
||||
assertEquals(1, collection.entries.size)
|
||||
assertEquals("MemberETag2", collection.entries.first().eTag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_RemoveVanishedMember() {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "old-ctag")
|
||||
entries += LocalTestResource().apply {
|
||||
fileName = "downloaded-member.txt"
|
||||
}
|
||||
}
|
||||
server.enqueue(queryCapabilitiesResponse(cTag = "new-ctag"))
|
||||
|
||||
val syncManager = syncManager(collection)
|
||||
syncManager.performSync()
|
||||
|
||||
assertFalse(syncManager.didGenerateUpload)
|
||||
assertTrue(syncManager.didListAllRemote)
|
||||
assertFalse(syncManager.didDownloadRemote)
|
||||
assertFalse(syncManager.syncResult.hasError())
|
||||
assertTrue(collection.entries.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformSync_CTagDidntChange() {
|
||||
val collection = LocalTestCollection().apply {
|
||||
lastSyncState = SyncState(SyncState.Type.CTAG, "ctag1")
|
||||
}
|
||||
server.enqueue(queryCapabilitiesResponse("ctag1"))
|
||||
|
||||
val syncManager = syncManager(collection)
|
||||
syncManager.performSync()
|
||||
|
||||
assertFalse(syncManager.didGenerateUpload)
|
||||
assertFalse(syncManager.didListAllRemote)
|
||||
assertFalse(syncManager.didDownloadRemote)
|
||||
assertFalse(syncManager.syncResult.hasError())
|
||||
assertTrue(collection.entries.isEmpty())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import at.bitfire.dav4jvm.DavCollection
|
||||
import at.bitfire.dav4jvm.DavResponseCallback
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.property.GetCTag
|
||||
import at.bitfire.davdroid.DavUtils
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.Assert.assertEquals
|
||||
|
||||
class TestSyncManager(
|
||||
context: Context,
|
||||
account: Account,
|
||||
extras: Bundle,
|
||||
authority: String,
|
||||
syncResult: SyncResult,
|
||||
localCollection: LocalTestCollection,
|
||||
val mockWebServer: MockWebServer
|
||||
): SyncManager<LocalTestResource, LocalTestCollection, DavCollection>(context, account, AccountSettings(context, account), extras, authority, syncResult, localCollection) {
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
collectionURL = mockWebServer.url("/")
|
||||
davCollection = DavCollection(httpClient.okHttpClient, collectionURL)
|
||||
return true
|
||||
}
|
||||
|
||||
var didQueryCapabilities = false
|
||||
override fun queryCapabilities(): SyncState? {
|
||||
if (didQueryCapabilities)
|
||||
throw IllegalStateException("queryCapabilities() must not be called twice")
|
||||
didQueryCapabilities = true
|
||||
|
||||
var cTag: SyncState? = null
|
||||
davCollection.propfind(0, GetCTag.NAME) { response, rel ->
|
||||
if (rel == Response.HrefRelation.SELF)
|
||||
response[GetCTag::class.java]?.cTag?.let {
|
||||
cTag = SyncState(SyncState.Type.CTAG, it)
|
||||
}
|
||||
}
|
||||
|
||||
return cTag
|
||||
}
|
||||
|
||||
var didGenerateUpload = false
|
||||
override fun generateUpload(resource: LocalTestResource): RequestBody {
|
||||
didGenerateUpload = true
|
||||
return resource.toString().toRequestBody()
|
||||
}
|
||||
|
||||
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
|
||||
|
||||
var listAllRemoteResult = emptyList<Pair<Response, Response.HrefRelation>>()
|
||||
var didListAllRemote = false
|
||||
override fun listAllRemote(callback: DavResponseCallback) {
|
||||
if (didListAllRemote)
|
||||
throw IllegalStateException("listAllRemote() must not be called twice")
|
||||
didListAllRemote = true
|
||||
for (result in listAllRemoteResult)
|
||||
callback(result.first, result.second)
|
||||
}
|
||||
|
||||
var assertDownloadRemote = emptyMap<HttpUrl, String>()
|
||||
var didDownloadRemote = false
|
||||
override fun downloadRemote(bunch: List<HttpUrl>) {
|
||||
didDownloadRemote = true
|
||||
assertEquals(assertDownloadRemote.keys.toList(), bunch)
|
||||
|
||||
for ((url, eTag) in assertDownloadRemote) {
|
||||
val fileName = DavUtils.lastSegmentOfUrl(url)
|
||||
var localEntry = localCollection.entries.filter { it.fileName == fileName }.firstOrNull()
|
||||
if (localEntry == null) {
|
||||
val newEntry = LocalTestResource().also {
|
||||
it.fileName = fileName
|
||||
}
|
||||
localCollection.entries += newEntry
|
||||
localEntry = newEntry
|
||||
}
|
||||
localEntry.eTag = eTag
|
||||
localEntry.flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
}
|
||||
}
|
||||
|
||||
override fun postProcess() {
|
||||
}
|
||||
|
||||
override fun notifyInvalidResourceTitle(): String {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.test.espresso.*
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.action.ViewActions
|
||||
import androidx.test.espresso.action.ViewActions.click
|
||||
import androidx.test.espresso.action.ViewActions.typeText
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.contrib.DrawerActions
|
||||
import androidx.test.espresso.contrib.DrawerMatchers.isClosed
|
||||
import androidx.test.espresso.matcher.ViewMatchers.*
|
||||
import androidx.test.espresso.util.HumanReadables
|
||||
import androidx.test.espresso.util.TreeIterables
|
||||
import androidx.test.ext.junit.rules.activityScenarioRule
|
||||
import androidx.test.filters.LargeTest
|
||||
import at.bitfire.davdroid.R
|
||||
import org.hamcrest.Description
|
||||
import org.hamcrest.Matcher
|
||||
import org.hamcrest.Matchers
|
||||
import org.hamcrest.TypeSafeMatcher
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.TimeoutException
|
||||
|
||||
@LargeTest
|
||||
class AccountsActivityEspressoTest {
|
||||
|
||||
@get:Rule
|
||||
val activityScenarioRule = activityScenarioRule<AccountsActivity>()
|
||||
|
||||
private val username = "test"
|
||||
private val password = "test"
|
||||
private val baseUrl = "https://davtest.dev001.net/radicale/htpasswd/"
|
||||
|
||||
|
||||
@Test
|
||||
fun accountsActivityTest() {
|
||||
skipIntroActivity()
|
||||
|
||||
onView(withId(R.id.fab)).perform(click())
|
||||
|
||||
// open first the option for Login with Base URL and then enter the test-data and confirm
|
||||
onView(withText(R.string.login_type_url)).perform(click())
|
||||
onView(withId(R.id.loginUrlBaseUrlEdittext)).perform(typeText(baseUrl), ViewActions.closeSoftKeyboard())
|
||||
onView(withId(R.id.loginUrlUsernameEdittext)).perform(typeText(username), ViewActions.closeSoftKeyboard())
|
||||
onView(withId(R.id.loginUrlPasswordEdittext)).perform(typeText(password), ViewActions.closeSoftKeyboard())
|
||||
onView(withId(R.id.login)).perform(click())
|
||||
|
||||
// The detect configuration screen (detect_configuration.xml) is not asserted here, it's just skipped.
|
||||
// login_account_details.xml is the next expected fragment, check if the expected headline appears and then click on the "Create Account" button
|
||||
onView(isRoot()).perform(waitForView(R.id.accountName, 30000))
|
||||
|
||||
onView(withText(R.string.login_account_name_info)).check(matches(isDisplayed()))
|
||||
onView(withId(R.id.create_account)).perform(click())
|
||||
|
||||
Thread.sleep(1000)
|
||||
|
||||
// check if the "test" calendar appeared
|
||||
onView(isRoot()).perform(waitForView(R.id.title, 5000))
|
||||
//onView(withId(R.id.title)).perform(waitForText("Test Addressbook", 30000))
|
||||
onView(withText("Test Addressbook")).check(matches(withText("Test Addressbook")))
|
||||
|
||||
// Go back to the overview with all Accounts
|
||||
Espresso.pressBack()
|
||||
//Thread.sleep(2000)
|
||||
|
||||
// check if the account exists and click on it
|
||||
onView(isRoot()).perform(waitForView((R.id.account_name), 5000))
|
||||
onView(withText("test")).check(matches(withText("test")))
|
||||
onView(withText("test")).perform(click())
|
||||
|
||||
// open the overflowMenu to delete the account
|
||||
val overflowMenuButton = onView(
|
||||
Matchers.allOf(withContentDescription("More options"),
|
||||
childAtPosition(
|
||||
childAtPosition(
|
||||
withId(R.id.toolbar),
|
||||
2),
|
||||
1),
|
||||
isDisplayed()))
|
||||
overflowMenuButton.perform(click())
|
||||
|
||||
// click on the delete button
|
||||
onView(withText(R.string.account_delete)).perform(click())
|
||||
onView(withText(R.string.account_delete_confirmation_title)).check(matches(isDisplayed()))
|
||||
// confirm deletion by clicking on YES
|
||||
onView(withId(android.R.id.button1)).perform(click())
|
||||
|
||||
// doublecheck to make sure that the account doesn't exist anymore. The welcome text is displayed
|
||||
onView(withText(R.string.account_list_empty)).check(matches(withText(R.string.account_list_empty)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun menuDrawerTest() {
|
||||
skipIntroActivity()
|
||||
|
||||
// TESTING ABOUT DIALOG
|
||||
// Open Drawer to click on navigation.
|
||||
onView(withId(R.id.drawer_layout))
|
||||
.check(matches(isClosed(Gravity.LEFT))) // Left Drawer should be closed.
|
||||
.perform(DrawerActions.open()) // Open Drawer
|
||||
// check if about can be opened
|
||||
onView(withText(R.string.navigation_drawer_about)).perform(click())
|
||||
onView(withText(R.string.about_copyright)).check(matches(isDisplayed()))
|
||||
Espresso.pressBack()
|
||||
|
||||
// TESTING SETTINGS DIALOG
|
||||
// Open Drawer to click on navigation.
|
||||
onView(withId(R.id.drawer_layout))
|
||||
.check(matches(isClosed(Gravity.LEFT))) // Left Drawer should be closed.
|
||||
.perform(DrawerActions.open()) // Open Drawer
|
||||
// check if about can be opened
|
||||
onView(withText(R.string.navigation_drawer_settings)).perform(click())
|
||||
onView(withText(R.string.app_settings_show_debug_info)).check(matches(isDisplayed()))
|
||||
Espresso.pressBack()
|
||||
|
||||
// TESTING WEBSITE MENU ENTRY
|
||||
// Open Drawer to click on navigation.
|
||||
onView(withId(R.id.drawer_layout))
|
||||
.check(matches(isClosed(Gravity.LEFT))) // Left Drawer should be closed.
|
||||
.perform(DrawerActions.open()) // Open Drawer
|
||||
// check if Website can be opened
|
||||
//onView(withText(R.string.navigation_drawer_website)).perform(click())
|
||||
}
|
||||
|
||||
|
||||
private fun childAtPosition(
|
||||
parentMatcher: Matcher<View>, position: Int): Matcher<View> {
|
||||
|
||||
return object : TypeSafeMatcher<View>() {
|
||||
override fun describeTo(description: Description) {
|
||||
description.appendText("Child at position $position in parent ")
|
||||
parentMatcher.describeTo(description)
|
||||
}
|
||||
|
||||
public override fun matchesSafely(view: View): Boolean {
|
||||
val parent = view.parent
|
||||
return parent is ViewGroup && parentMatcher.matches(parent)
|
||||
&& view == parent.getChildAt(position)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun skipIntroActivity() {
|
||||
try {
|
||||
onView(withId(R.id.takeControl)).check(matches(withText(R.string.intro_slogan2))) // intro_welcome is the first fragment, check first if the String Resource "intro_slogan2" is shown.
|
||||
// click through up to 5 intro fragments
|
||||
for (i in 1..5)
|
||||
try {
|
||||
onView(withId(R.id.next)).perform(click())
|
||||
} catch (ignored: Exception) { }
|
||||
onView(withId(R.id.done)).perform(click())
|
||||
} catch (ignored: NoMatchingViewException) {
|
||||
// the IntroActivity or some fragments of it may not show up every time
|
||||
}
|
||||
onView(withText(R.string.account_list_empty)).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
/**
|
||||
* This ViewAction tells espresso to wait till a certain view is found in the view hierarchy.
|
||||
* Source: https://www.repeato.app/espresso-wait-for-view/
|
||||
* @param viewId The id of the view to wait for.
|
||||
* @param timeout The maximum time which espresso will wait for the view to show up (in milliseconds)
|
||||
*/
|
||||
private fun waitForView(viewId: Int, timeout: Long): ViewAction {
|
||||
return object : ViewAction {
|
||||
override fun getConstraints(): Matcher<View> {
|
||||
return isRoot()
|
||||
}
|
||||
|
||||
override fun getDescription(): String {
|
||||
return "wait for a specific view with id $viewId; during $timeout millis."
|
||||
}
|
||||
|
||||
override fun perform(uiController: UiController, rootView: View) {
|
||||
uiController.loopMainThreadUntilIdle()
|
||||
val startTime = System.currentTimeMillis()
|
||||
val endTime = startTime + timeout
|
||||
val viewMatcher = withId(viewId)
|
||||
|
||||
do {
|
||||
// Iterate through all views on the screen and see if the view we are looking for is there already
|
||||
for (child in TreeIterables.breadthFirstViewTraversal(rootView)) {
|
||||
// found view with required ID
|
||||
if (viewMatcher.matches(child)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Loops the main thread for a specified period of time.
|
||||
// Control may not return immediately, instead it'll return after the provided delay has passed and the queue is in an idle state again.
|
||||
uiController.loopMainThreadForAtLeast(100)
|
||||
} while (System.currentTimeMillis() < endTime) // in case of a timeout we throw an exception -> test fails
|
||||
throw PerformException.Builder()
|
||||
.withCause(TimeoutException())
|
||||
.withActionDescription(this.description)
|
||||
.withViewDescription(HumanReadables.describe(rootView))
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.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;
|
||||
HttpClient 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 = new HttpClient.Builder()
|
||||
.addAuthentication(null, credentials.getUserName(), credentials.getPassword())
|
||||
.build();
|
||||
}
|
||||
|
||||
@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.getOkHttpClient(), 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.getOkHttpClient(), 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,208 +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 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.dispatcher = TestDispatcher()
|
||||
server.start()
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testQueryEmailAddress() {
|
||||
var info = ServiceInfo()
|
||||
assertArrayEquals(
|
||||
arrayOf("email1@example.com", "email2@example.com"),
|
||||
finder.queryEmailAddress(server.url(PATH_CALDAV + SUBPATH_PRINCIPAL)).toTypedArray()
|
||||
)
|
||||
assertTrue(finder.queryEmailAddress(server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)).isEmpty())
|
||||
}
|
||||
|
||||
|
||||
// mock server
|
||||
|
||||
class TestDispatcher: Dispatcher() {
|
||||
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
if (!checkAuth(request)) {
|
||||
val authenticate = MockResponse().setResponseCode(401)
|
||||
authenticate.setHeader("WWW-Authenticate", "Basic realm=\"test\"")
|
||||
return authenticate
|
||||
}
|
||||
|
||||
val path = request.path!!
|
||||
|
||||
if (request.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 (request.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>"
|
||||
|
||||
PATH_CALDAV + SUBPATH_PRINCIPAL ->
|
||||
props = "<CAL:calendar-user-address-set>" +
|
||||
" <href>urn:unknown-entry</href>" +
|
||||
" <href>mailto:email1@example.com</href>" +
|
||||
" <href>mailto:email2@example.com</href>" +
|
||||
"</CAL:calendar-user-address-set>"
|
||||
|
||||
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' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
" <href>${request.path}</href>" +
|
||||
" <propstat><prop>$props</prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>")
|
||||
}
|
||||
|
||||
return MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
private fun checkAuth(rq: RecordedRequest) =
|
||||
rq.getHeader("Authorization") == "Basic bW9jazoxMjM0NQ=="
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) Ricki Hirner (bitfire web engineering).
|
||||
~ 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
|
||||
@@ -9,6 +9,6 @@
|
||||
|
||||
<resources>
|
||||
|
||||
<string name="app_name">Davx5Test</string>
|
||||
<string name="app_name">DavdroidTest</string>
|
||||
|
||||
</resources>
|
||||
@@ -1,3 +0,0 @@
|
||||
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accountType="at.bitfire.davdroid.SyncManagerTest"
|
||||
android:label="Test Account" />
|
||||
+109
-140
@@ -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"
|
||||
@@ -7,12 +14,10 @@
|
||||
<!-- normal permissions -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS"/>
|
||||
<uses-permission android:name="android.permission.READ_SYNC_STATS"/>
|
||||
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||
|
||||
<!-- account management permissions not required for own accounts since API level 22 -->
|
||||
@@ -20,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"/>
|
||||
@@ -28,113 +40,43 @@
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR"/>
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR"/>
|
||||
|
||||
<!-- android.permission-group.LOCATION -->
|
||||
<!-- getting the WiFi name (for "sync in Wifi only") requires
|
||||
- coarse location (Android 8.1)
|
||||
- fine location (Android 10) -->
|
||||
<uses-permission-sdk-23 android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
<uses-permission-sdk-23 android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<!-- required since Android 10 to get the WiFi name while in background (= while syncing) -->
|
||||
<uses-permission-sdk-23 android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
|
||||
|
||||
<!-- ical4android declares task access permissions -->
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="false"
|
||||
android:allowBackup="true"
|
||||
android:fullBackupContent="false"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppTheme"
|
||||
android:resizeableActivity="true"
|
||||
tools:ignore="UnusedAttribute"
|
||||
android:supportsRtl="true">
|
||||
tools:ignore="UnusedAttribute">
|
||||
|
||||
<service android:name=".DavService"/>
|
||||
<service android:name=".ForegroundService"/>
|
||||
|
||||
<activity android:name=".ui.intro.IntroActivity" android:theme="@style/AppTheme.NoActionBar" />
|
||||
<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"/>
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<action android:name="android.intent.action.PACKAGE_REPLACED" />
|
||||
<data android:scheme="package" android:path="at.bitfire.davdroid" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.DebugInfoActivity"
|
||||
android:parentActivityName=".ui.AppSettingsActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/debug_info_title">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BUG_REPORT"/>
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.PermissionsActivity"
|
||||
android:label="@string/app_settings_security_app_permissions"
|
||||
android:parentActivityName=".ui.AppSettingsActivity" />
|
||||
<activity
|
||||
android:name=".ui.TasksActivity"
|
||||
android:label="@string/intro_tasks_title"
|
||||
android:parentActivityName=".ui.AppSettingsActivity" />
|
||||
</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.account.CreateAddressBookActivity"
|
||||
android:label="@string/create_addressbook"
|
||||
android:parentActivityName=".ui.account.AccountActivity" />
|
||||
<activity
|
||||
android:name=".ui.account.CreateCalendarActivity"
|
||||
android:label="@string/create_calendar"
|
||||
android:parentActivityName=".ui.account.AccountActivity" />
|
||||
<activity
|
||||
android:name=".ui.account.SettingsActivity"
|
||||
android:parentActivityName=".ui.account.AccountActivity" />
|
||||
<activity
|
||||
android:name=".ui.account.WifiPermissionsActivity"
|
||||
android:label="@string/wifi_permissions_label"
|
||||
android:parentActivityName=".ui.account.SettingsActivity" />
|
||||
|
||||
<!-- account type "DAVx⁵" -->
|
||||
<!-- account type "DAVdroid" -->
|
||||
<service
|
||||
android:name=".syncadapter.AccountAuthenticatorService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.AccountAuthenticator"/>
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.accounts.AccountAuthenticator"
|
||||
android:resource="@xml/account_authenticator"/>
|
||||
@@ -142,41 +84,35 @@
|
||||
<service
|
||||
android:name=".syncadapter.CalendarsSyncAdapterService"
|
||||
android:exported="true"
|
||||
android:process=":sync"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/sync_calendars"/>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".syncadapter.OpenTasksSyncAdapterService"
|
||||
android:name=".syncadapter.TasksSyncAdapterService"
|
||||
android:exported="true"
|
||||
android:process=":sync"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/sync_opentasks"/>
|
||||
</service>
|
||||
<service
|
||||
android:name=".syncadapter.TasksOrgSyncAdapterService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.content.SyncAdapter"
|
||||
android:resource="@xml/sync_tasks_org"/>
|
||||
android:resource="@xml/sync_tasks"/>
|
||||
</service>
|
||||
|
||||
<!-- account type "DAVx⁵ Address book" -->
|
||||
<!-- account type "DAVdroid Address book" -->
|
||||
<service
|
||||
android:name=".syncadapter.NullAuthenticatorService"
|
||||
android:exported="true"> <!-- Since Android 11, this must be true so that Google Contacts shows the address book accounts -->
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.AccountAuthenticator"/>
|
||||
</intent-filter>
|
||||
@@ -194,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"/>
|
||||
@@ -206,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"/>
|
||||
@@ -219,54 +157,85 @@
|
||||
android:resource="@xml/contacts"/>
|
||||
</service>
|
||||
|
||||
<receiver android:name=".BootCompletedReceiver">
|
||||
<service
|
||||
android:name=".DavService"
|
||||
android:enabled="true">
|
||||
</service>
|
||||
|
||||
<receiver android:name=".AccountsChangedReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.accounts.LOGIN_ACCOUNTS_CHANGED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- provider to share debug info/logs -->
|
||||
<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="androidx.core.content.FileProvider"
|
||||
android:authorities="@string/authority_debug_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/debug_paths" />
|
||||
android:resource="@xml/log_paths" />
|
||||
</provider>
|
||||
|
||||
|
||||
</application>
|
||||
|
||||
<!-- package visiblity – which apps do we need to see? -->
|
||||
<queries>
|
||||
<!-- task providers -->
|
||||
<package android:name="org.dmfs.tasks" />
|
||||
<package android:name="org.tasks" />
|
||||
|
||||
<!-- ICSx5 for Webcal feeds -->
|
||||
<package android:name="at.bitfire.icsdroid"/>
|
||||
|
||||
<!-- apps that interact with contact, calendar, task data (for debug info) -->
|
||||
<intent>
|
||||
<action android:name="*" />
|
||||
<data android:scheme="content" android:host="com.android.contacts" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="*" />
|
||||
<data android:scheme="content" android:host="com.android.calendar" />
|
||||
</intent>
|
||||
|
||||
<!-- Web browsers (to check whether a Web browser is installed before viewing a URL) -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="https" />
|
||||
</intent>
|
||||
|
||||
<!-- Custom Tabs support (e.g. Nextcloud Login Flow) -->
|
||||
<intent>
|
||||
<action android:name="android.support.customtabs.action.CustomTabsService" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
</manifest>
|
||||
@@ -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>
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
carddav.a1.net
|
||||
aol.com
|
||||
dav.edis.at
|
||||
dav.fruux.com
|
||||
caldav.gmx.net
|
||||
carddav.gmx.net
|
||||
icloud.com
|
||||
cloud.liberta.vip
|
||||
office.luckycloud.de
|
||||
calendar.mail.ru
|
||||
mailbox.org
|
||||
mailfence.com
|
||||
posteo.de:8443
|
||||
live.teambox.eu
|
||||
spica.t-online.de
|
||||
caldav.calendar.yahoo.com
|
||||
yandex.ru
|
||||
webmail.your-server.de/rpc.php/
|
||||
calendar.zoho.com
|
||||
calendar.zoho.eu
|
||||
contacts.zoho.com
|
||||
contacts.zoho.eu
|
||||
@@ -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>
|
||||
@@ -1 +0,0 @@
|
||||
{"ar_SA":["abdunnasir"],"bg":["dpa_transifex","ma4ko"],"ca":["ettenmoors","hegocr_1709","jordibrus","Kintu","Solatec","zagur"],"cs":["Kryšpin","pavelb","tomas.odehnal"],"da":["knutztar","mjjzf","Tntdruid_","twikedk"],"de":["amandablue","anestiskaci","Atalanttore","corppneq","crit12","Gyges","hammaschlach","maxkl","nicolas_git","owncube","TheName","Waldmeisda","Wyrrrd","YvanM"],"el":["anestiskaci","diamond_gr","KristinaQejvanaj"],"es":["aluaces","Anoih","Ark74","Elhea","GranPC","jcvielma","oscfdezdz","plaguna","polkhas","sfouilleulb","xphnx"],"eu":["cockeredradiation","Osoitz","Thadah"],"fa":["ahangarha","amiraliakbari","javad.aboutorabi","joojoojoo","maryambehzi","mtashackori","Numb","taranehsaei"],"fi_FI":["raketti","tseipii"],"fr":["AlainR","alkino2","Amadeen","boutil","callmemagnus","chfo","chrcha","Floflr","grenatrad","jokx","Jorg722","JorisBodin","Llorc","LoiX07","mathieugfortin","Novick","paullbn","Poussinou","RomainTT","Synochy","Thecross","unareil","vincen","YvanM","ÉricB."],"fr_FR":["chrcha","Llorc","Poussinou","sfouilleulb"],"gl":["aluaces","pikamoku"],"hr":["tomiis"],"hr_HR":["tomiis"],"hu":["infeeeee","jtg","meskobalazs","Roshek","sfouilleulb"],"it":["Damtux","ed0","FranzMari","lzambarda","malaerba","noccio","nwandy","rickyroo","sfouilleulb","technezio"],"it_IT":["malaerba"],"ja":["Naofumi","yanorei32"],"nb_NO":["elonus"],"nl":["davtemp","dehart","erikhubers","frankyboy1963","glotzbach","toonvangerwen","XtremeNova"],"pl":["gsz","M4SK1N","mg6","oskarjakiela","TheName","TORminator","Valdnet"],"pt":["amalvarenga","wanderlei.huttel"],"pt_BR":["wanderlei.huttel"],"ru":["aigoshin","anm","ashed","astalavister","nick.savin","vaddd"],"sk_SK":["brango67","tiborepcek"],"sl_SI":["MrLaaky","uroszor"],"sr":["daimonion"],"sv":["campbelldavid","Mikaelb"],"szl":["chlodny"],"tr_TR":["ooguz","pultars"],"uk":["androsua","olexn","twixi007"],"uk_UA":["astalavister"],"vi":["bruhwut"],"zh_CN":["anolir","jxj2zzz79pfp9bpo","leoniscsem","linuxbckp","mofitt2016","oksjd","phy","spice2wolf"],"zh_TW":["linuxbckp","mofitt2016","phy","waiabsfabuloushk"]}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,487 @@
|
||||
/*
|
||||
* 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"
|
||||
|
||||
/* Whether DAVdroid populates and uses CalendarContract.Colors
|
||||
value = null (not existing) false (default)
|
||||
"1" true */
|
||||
val KEY_EVENT_COLORS = "event_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")
|
||||
|
||||
fun getEventColors() = 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 = 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, 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")
|
||||
if (url == null)
|
||||
Logger.log.info("No address book URL, ignoring account")
|
||||
else {
|
||||
// create new address book
|
||||
val info = CollectionInfo(url)
|
||||
info.type = CollectionInfo.Type.ADDRESS_BOOK
|
||||
info.displayName = account.name
|
||||
Logger.log.log(Level.INFO, "Creating new address book account", url)
|
||||
val addressBookAccount = Account(LocalAddressBook.accountName(account, info), context.getString(R.string.account_type_address_book))
|
||||
if (!accountManager.addAccountExplicitly(addressBookAccount, null, LocalAddressBook.initialUserData(account, info.url)))
|
||||
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, context.getString(R.string.address_books_authority), 1)
|
||||
setSyncInterval(context.getString(R.string.address_books_authority), Constants.DEFAULT_SYNC_INTERVAL)
|
||||
}
|
||||
|
||||
/* Android 7.1.1 OpenTasks fix */
|
||||
@Suppress("unused")
|
||||
private fun update_4_5() {
|
||||
// call PackageChangedReceiver which then enables/disables OpenTasks sync when it's (not) available
|
||||
PackageChangedReceiver.updateTaskSync(context)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun update_3_4() {
|
||||
setGroupMethod(GroupMethod.CATEGORIES)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.content.Context
|
||||
|
||||
abstract class AndroidSingleton<T> {
|
||||
|
||||
var creatingSingleton = false
|
||||
var singleton: T? = null
|
||||
|
||||
@Synchronized
|
||||
fun getInstance(context: Context): T {
|
||||
singleton?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
if (creatingSingleton)
|
||||
throw IllegalStateException("AndroidSingleton::getInstance() must not be called while createInstance()")
|
||||
creatingSingleton = true
|
||||
|
||||
val newSingleton = createInstance(context.applicationContext)
|
||||
singleton = newSingleton
|
||||
|
||||
creatingSingleton = false
|
||||
return newSingleton
|
||||
}
|
||||
|
||||
protected abstract fun createInstance(context: Context): T
|
||||
|
||||
}
|
||||
@@ -10,90 +10,43 @@ package at.bitfire.davdroid
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.StrictMode
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.davdroid.ui.UiUtils
|
||||
import java.util.logging.Level
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
@Suppress("unused")
|
||||
class App: Application(), Thread.UncaughtExceptionHandler {
|
||||
class App: Application() {
|
||||
|
||||
companion object {
|
||||
|
||||
fun getLauncherBitmap(context: Context) =
|
||||
AppCompatResources.getDrawable(context, R.mipmap.ic_launcher)?.toBitmap()
|
||||
@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"
|
||||
|
||||
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()!!
|
||||
@JvmField val OVERRIDE_PROXY_HOST_DEFAULT = "localhost"
|
||||
@JvmField val OVERRIDE_PROXY_PORT_DEFAULT = 8118
|
||||
|
||||
|
||||
@JvmStatic
|
||||
fun getLauncherBitmap(context: Context): Bitmap? {
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Logger.initialize(this)
|
||||
|
||||
if (BuildConfig.DEBUG)
|
||||
// debug builds
|
||||
StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder()
|
||||
.detectActivityLeaks()
|
||||
.detectFileUriExposure()
|
||||
.detectLeakedClosableObjects()
|
||||
.detectLeakedRegistrationObjects()
|
||||
.detectLeakedSqlLiteObjects()
|
||||
.penaltyLog()
|
||||
.build())
|
||||
else // if (BuildConfig.FLAVOR == FLAVOR_STANDARD)
|
||||
// handle uncaught exceptions in non-debug standard flavor
|
||||
Thread.setDefaultUncaughtExceptionHandler(this)
|
||||
|
||||
NotificationUtils.createChannels(this)
|
||||
|
||||
// set light/dark mode
|
||||
UiUtils.setTheme(this) // when this is called in the asynchronous thread below, it recreates
|
||||
// some current activity and causes an IllegalStateException in rare cases
|
||||
|
||||
// don't block UI for some background checks
|
||||
thread {
|
||||
// create/update app shortcuts
|
||||
UiUtils.updateShortcuts(this)
|
||||
|
||||
// watch installed/removed apps
|
||||
TasksWatcher(this)
|
||||
|
||||
// check whether a tasks app is currently installed
|
||||
TasksWatcher.updateTaskSync(this)
|
||||
|
||||
// check/repair sync intervals
|
||||
AccountSettings.repairSyncIntervals(this)
|
||||
|
||||
// foreground service (possible workaround for devices which prevent DAVx5 from being started)
|
||||
ForegroundService.startIfEnabled(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.EXTRA_CAUSE, e)
|
||||
startActivity(intent)
|
||||
|
||||
exitProcess(1)
|
||||
Logger.reinitLogger(this)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
|
||||
/**
|
||||
* There are circumstances when Android drops automatic sync of accounts and resets them
|
||||
* to manual synchronization, for instance when the device is booted into safe mode.
|
||||
*
|
||||
* This receiver causes the app to be started when the device is rebooted. When the app
|
||||
* is started, it checks (and repairs, if necessary) the sync intervals in [App.onCreate].
|
||||
*/
|
||||
class BootCompletedReceiver: BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Logger.log.info("Device has been rebooted; checking sync intervals etc.")
|
||||
// sync intervals are checked in App.onCreate()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,24 +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
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
}
|
||||
@@ -9,99 +9,66 @@
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.IntentService
|
||||
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.annotation.WorkerThread
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.dav4jvm.exception.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.Collection
|
||||
import at.bitfire.davdroid.model.*
|
||||
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.collections.*
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
class DavService: IntentService("DavService") {
|
||||
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,
|
||||
Owner.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"
|
||||
}
|
||||
|
||||
/**
|
||||
* List of [Service] IDs for which the collections are currently refreshed
|
||||
*/
|
||||
private val runningRefresh = Collections.synchronizedSet(HashSet<Long>())
|
||||
private val runningRefresh = HashSet<Long>()
|
||||
private val refreshingStatusListeners = LinkedList<WeakReference<RefreshingStatusListener>>()
|
||||
|
||||
/**
|
||||
* Currently registered [RefreshingStatusListener]s, which will be notified
|
||||
* when a collection refresh status changes
|
||||
*/
|
||||
private val refreshingStatusListeners = Collections.synchronizedList(LinkedList<WeakReference<RefreshingStatusListener>>())
|
||||
|
||||
@WorkerThread
|
||||
override fun onHandleIntent(intent: Intent?) {
|
||||
if (intent == null)
|
||||
return
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
intent?.let {
|
||||
val id = intent.getLongExtra(EXTRA_DAV_SERVICE_ID, -1)
|
||||
|
||||
val id = intent.getLongExtra(EXTRA_DAV_SERVICE_ID, -1)
|
||||
|
||||
when (intent.action) {
|
||||
ACTION_REFRESH_COLLECTIONS ->
|
||||
if (runningRefresh.add(id)) {
|
||||
refreshingStatusListeners.forEach { listener ->
|
||||
listener.get()?.onDavRefreshStatusChanged(id, true)
|
||||
when (intent.action) {
|
||||
ACTION_ACCOUNTS_UPDATED ->
|
||||
cleanupAccounts()
|
||||
ACTION_REFRESH_COLLECTIONS ->
|
||||
if (runningRefresh.add(id)) {
|
||||
thread { refreshCollections(id) }
|
||||
refreshingStatusListeners.forEach { it.get()?.onDavRefreshStatusChanged(id, true) }
|
||||
}
|
||||
|
||||
val db = AppDatabase.getInstance(this@DavService)
|
||||
refreshCollections(db, id)
|
||||
}
|
||||
|
||||
ACTION_FORCE_SYNC -> {
|
||||
val uri = intent.data!!
|
||||
val authority = uri.authority!!
|
||||
val account = Account(
|
||||
uri.pathSegments[1],
|
||||
uri.pathSegments[0]
|
||||
)
|
||||
forceSync(authority, account)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
|
||||
@@ -118,20 +85,17 @@ class DavService: IntentService("DavService") {
|
||||
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)
|
||||
synchronized(runningRefresh) {
|
||||
for (id in runningRefresh)
|
||||
listener.onDavRefreshStatusChanged(id, true)
|
||||
}
|
||||
if (callImmediate)
|
||||
runningRefresh.forEach { id -> listener.onDavRefreshStatusChanged(id, true) }
|
||||
}
|
||||
|
||||
fun removeRefreshingStatusListener(listener: RefreshingStatusListener) {
|
||||
val iter = refreshingStatusListeners.iterator()
|
||||
while (iter.hasNext()) {
|
||||
val item = iter.next().get()
|
||||
if (item == listener || item == null)
|
||||
if (listener == item)
|
||||
iter.remove()
|
||||
}
|
||||
}
|
||||
@@ -145,262 +109,274 @@ class DavService: IntentService("DavService") {
|
||||
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(getString(R.string.account_type_address_book))
|
||||
.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(db: AppDatabase, serviceId: Long) {
|
||||
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.
|
||||
*
|
||||
* @param personal Whether this is the "outer" call of the recursion.
|
||||
*
|
||||
* *true* = found home sets belong to the current-user-principal; recurse if
|
||||
* calendar proxies or group memberships are found
|
||||
*
|
||||
* *false* = found home sets don't directly belong to the current-user-principal; don't recurse
|
||||
*
|
||||
* @throws java.io.IOException
|
||||
* @throws HttpException
|
||||
* @throws at.bitfire.dav4jvm.exception.DavException
|
||||
*/
|
||||
fun queryHomeSets(client: OkHttpClient, url: HttpUrl, personal: 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, personal, foundUrl)
|
||||
fun saveHomeSets() {
|
||||
db.delete(HomeSets._TABLE, "${HomeSets.SERVICE_ID}=?", arrayOf(service.toString()))
|
||||
for (homeSet in homeSets) {
|
||||
val values = ContentValues(2)
|
||||
values.put(HomeSets.SERVICE_ID, service)
|
||||
values.put(HomeSets.URL, homeSet.toString())
|
||||
db.insertOrThrow(HomeSets._TABLE, null, values)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveCollections() {
|
||||
db.delete(Collections._TABLE, "${HomeSets.SERVICE_ID}=?", arrayOf(service.toString()))
|
||||
for ((_,collection) in collections) {
|
||||
val values = collection.toDB()
|
||||
Logger.log.log(Level.FINE, "Saving collection", values)
|
||||
values.put(Collections.SERVICE_ID, service)
|
||||
db.insertWithOnConflict(Collections._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
Logger.log.info("Refreshing $serviceType collections of service #$service")
|
||||
|
||||
// create authenticating OkHttpClient (credentials taken from account settings)
|
||||
HttpClient.Builder(this@DavService, account)
|
||||
.setForeground(true)
|
||||
.build().use { client ->
|
||||
val httpClient = client.okHttpClient
|
||||
|
||||
// refresh home set list (from principal)
|
||||
readPrincipal()?.let { principalUrl ->
|
||||
Logger.log.fine("Querying principal $principalUrl for home sets")
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (personal)
|
||||
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, personal, foundUrl)
|
||||
}
|
||||
}
|
||||
|
||||
if (personal)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
// remember selected collections
|
||||
val selectedCollections = HashSet<HttpUrl>()
|
||||
collections.values
|
||||
.filter { it.selected }
|
||||
.forEach { (url,_) -> HttpUrl.parse(url)?.let { selectedCollections.add(it) } }
|
||||
|
||||
// query related homesets (those that do not belong to the current-user-principal)
|
||||
for (resource in related)
|
||||
queryHomeSets(client, resource, false)
|
||||
}
|
||||
// now refresh collections (taken from home sets)
|
||||
val itHomeSets = homeSets.iterator()
|
||||
while (itHomeSets.hasNext()) {
|
||||
val homeSetUrl = itHomeSets.next()
|
||||
Logger.log.fine("Listing home set $homeSetUrl")
|
||||
|
||||
fun saveHomesets() {
|
||||
DaoTools(homeSetDao).syncAll(
|
||||
homeSetDao.getByService(serviceId),
|
||||
homeSets,
|
||||
{ it.url })
|
||||
}
|
||||
|
||||
fun saveCollections() {
|
||||
DaoTools(collectionDao).syncAll(
|
||||
collectionDao.getByService(serviceId),
|
||||
collections, { it.url }) { new, old ->
|
||||
new.forceReadOnly = old.forceReadOnly
|
||||
new.sync = old.sync
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// refresh home set list (from principal)
|
||||
service.principal?.let { principalUrl ->
|
||||
Logger.log.fine("Querying principal $principalUrl for home sets")
|
||||
queryHomeSets(httpClient, principalUrl)
|
||||
}
|
||||
|
||||
// now refresh homesets and their member collections
|
||||
val itHomeSets = homeSets.iterator()
|
||||
while (itHomeSets.hasNext()) {
|
||||
val (homeSetUrl, homeSet) = itHomeSets.next()
|
||||
Logger.log.fine("Listing home set $homeSetUrl")
|
||||
|
||||
try {
|
||||
DavResource(httpClient, homeSetUrl).propfind(1, *DAV_COLLECTION_PROPERTIES) { response, relation ->
|
||||
if (!response.isSuccess())
|
||||
return@propfind
|
||||
|
||||
if (relation == Response.HrefRelation.SELF) {
|
||||
// this response is about the homeset itself
|
||||
homeSet.displayName = response[DisplayName::class.java]?.displayName
|
||||
homeSet.privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind ?: true
|
||||
}
|
||||
|
||||
// in any case, check whether the response is about a useable collection
|
||||
val info = Collection.fromDavResponse(response) ?: return@propfind
|
||||
info.serviceId = serviceId
|
||||
info.refHomeSet = homeSet
|
||||
info.confirmed = true
|
||||
info.owner = response[Owner::class.java]?.href?.let { response.href.resolve(it) }
|
||||
Logger.log.log(Level.FINE, "Found collection", info)
|
||||
|
||||
// remember usable collections
|
||||
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
|
||||
}
|
||||
} catch(e: HttpException) {
|
||||
if (e.code in arrayOf(403, 404, 410))
|
||||
// delete home set only if it was not accessible (40x)
|
||||
itHomeSets.remove()
|
||||
}
|
||||
}
|
||||
|
||||
// check/refresh unconfirmed collections
|
||||
val itCollections = collections.entries.iterator()
|
||||
while (itCollections.hasNext()) {
|
||||
val (url, info) = itCollections.next()
|
||||
if (!info.confirmed)
|
||||
val homeSet = DavResource(httpClient, homeSetUrl)
|
||||
try {
|
||||
// this collection doesn't belong to a homeset anymore, otherwise it would have been confirmed
|
||||
info.homeSetId = null
|
||||
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)
|
||||
|
||||
DavResource(httpClient, url).propfind(0, *DAV_COLLECTION_PROPERTIES) { response, _ ->
|
||||
if (!response.isSuccess())
|
||||
return@propfind
|
||||
|
||||
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()
|
||||
if ((serviceType == Services.SERVICE_CARDDAV && info.type == CollectionInfo.Type.ADDRESS_BOOK) ||
|
||||
(serviceType == Services.SERVICE_CALDAV && arrayOf(CollectionInfo.Type.CALENDAR, CollectionInfo.Type.WEBCAL).contains(info.type)))
|
||||
collections[member.location] = info
|
||||
}
|
||||
} catch(e: HttpException) {
|
||||
if (e.code in arrayOf(403, 404, 410))
|
||||
// delete collection only if it was not accessible (40x)
|
||||
itCollections.remove()
|
||||
else
|
||||
throw e
|
||||
if (e.status in arrayOf(403, 404, 410))
|
||||
// delete home set only if it was not accessible (40x)
|
||||
itHomeSets.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.runInTransaction {
|
||||
saveHomesets()
|
||||
|
||||
// use refHomeSet (if available) to determine homeset ID
|
||||
for (collection in collections.values)
|
||||
collection.refHomeSet?.let { homeSet ->
|
||||
collection.homeSetId = homeSet.id
|
||||
}
|
||||
saveCollections()
|
||||
}
|
||||
|
||||
} 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)
|
||||
// check/refresh unconfirmed collections
|
||||
val itCollections = collections.entries.iterator()
|
||||
while (itCollections.hasNext()) {
|
||||
val (url, info) = itCollections.next()
|
||||
if (!info.confirmed)
|
||||
try {
|
||||
val collection = DavResource(httpClient, url)
|
||||
collection.propfind(0, *CollectionInfo.DAV_PROPERTIES)
|
||||
val info = CollectionInfo(collection)
|
||||
info.confirmed = true
|
||||
|
||||
val debugIntent = Intent(this, DebugInfoActivity::class.java)
|
||||
debugIntent.putExtra(DebugInfoActivity.EXTRA_CAUSE, e)
|
||||
debugIntent.putExtra(DebugInfoActivity.EXTRA_ACCOUNT, account)
|
||||
// remove unusable collections
|
||||
if ((serviceType == Services.SERVICE_CARDDAV && info.type != CollectionInfo.Type.ADDRESS_BOOK) ||
|
||||
(serviceType == Services.SERVICE_CALDAV && !arrayOf(CollectionInfo.Type.CALENDAR, CollectionInfo.Type.WEBCAL).contains(info.type)) ||
|
||||
(info.type == CollectionInfo.Type.WEBCAL && info.source == null))
|
||||
itCollections.remove()
|
||||
} catch(e: HttpException) {
|
||||
if (e.status in arrayOf(403, 404, 410))
|
||||
// delete collection only if it was not accessible (40x)
|
||||
itCollections.remove()
|
||||
else
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
// restore selections
|
||||
for (url in selectedCollections)
|
||||
collections[url]?.let { it.selected = true }
|
||||
}
|
||||
|
||||
db.beginTransactionNonExclusive()
|
||||
try {
|
||||
saveHomeSets()
|
||||
saveCollections()
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
|
||||
} catch(e: InvalidAccountException) {
|
||||
Logger.log.log(Level.SEVERE, "Invalid account", e)
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't refresh collection list", e)
|
||||
|
||||
val debugIntent = Intent(this@DavService, DebugInfoActivity::class.java)
|
||||
debugIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
|
||||
debugIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account)
|
||||
|
||||
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,199 +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 android.provider.ContactsContract
|
||||
import androidx.core.content.getSystemService
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.TaskUtils
|
||||
import okhttp3.HttpUrl
|
||||
import org.xbill.DNS.*
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Some WebDAV and related network utility methods
|
||||
*/
|
||||
object DavUtils {
|
||||
|
||||
enum class SyncStatus {
|
||||
ACTIVE, PENDING, IDLE
|
||||
}
|
||||
|
||||
|
||||
@Suppress("FunctionName")
|
||||
@JvmStatic
|
||||
fun ARGBtoCalDAVColor(colorWithAlpha: Int): String {
|
||||
val alpha = (colorWithAlpha shr 24) and 0xFF
|
||||
val color = colorWithAlpha and 0xFFFFFF
|
||||
return String.format(Locale.ROOT, "#%06X%02X", color, alpha)
|
||||
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(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
|
||||
|
||||
|
||||
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<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<out Record>?): SRVRecord? {
|
||||
if (records == null)
|
||||
return null
|
||||
|
||||
val srvRecords = records.filterIsInstance(SRVRecord::class.java)
|
||||
if (srvRecords.size <= 1)
|
||||
return srvRecords.firstOrNull()
|
||||
|
||||
/* RFC 2782
|
||||
|
||||
Priority
|
||||
The priority of this target host. A client MUST attempt to
|
||||
contact the target host with the lowest-numbered priority it can
|
||||
reach; target hosts with the same priority SHOULD be tried in an
|
||||
order defined by the weight field. [...]
|
||||
|
||||
Weight
|
||||
A server selection mechanism. The weight field specifies a
|
||||
relative weight for entries with the same priority. [...]
|
||||
|
||||
To select a target to be contacted next, arrange all SRV RRs
|
||||
(that have not been ordered yet) in any order, except that all
|
||||
those with weight 0 are placed at the beginning of the list.
|
||||
|
||||
Compute the sum of the weights of those RRs, and with each RR
|
||||
associate the running sum in the selected order. Then choose a
|
||||
uniform random number between 0 and the sum computed
|
||||
(inclusive), and select the RR whose running sum value is the
|
||||
first in the selected order which is greater than or equal to
|
||||
the random number selected. The target host specified in the
|
||||
selected SRV RR is the next one to be contacted by the client.
|
||||
*/
|
||||
val minPriority = srvRecords.map { it.priority }.minOrNull()
|
||||
val useableRecords = srvRecords.filter { it.priority == minPriority }.sortedBy { it.weight != 0 }
|
||||
|
||||
val map = TreeMap<Int, SRVRecord>()
|
||||
var runningWeight = 0
|
||||
for (record in useableRecords) {
|
||||
val weight = record.weight
|
||||
runningWeight += weight
|
||||
map[runningWeight] = record
|
||||
}
|
||||
|
||||
val selector = (0..runningWeight).random()
|
||||
return map.ceilingEntry(selector)!!.value
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the sync status of a given account. Checks the account itself and possible
|
||||
* sub-accounts (address book accounts).
|
||||
*
|
||||
* @param authorities sync authorities to check (usually taken from [syncAuthorities])
|
||||
*
|
||||
* @return sync status of the given account
|
||||
*/
|
||||
fun accountSyncStatus(context: Context, authorities: Iterable<String>, account: Account): SyncStatus {
|
||||
// check active syncs
|
||||
if (authorities.any { ContentResolver.isSyncActive(account, it) })
|
||||
return SyncStatus.ACTIVE
|
||||
|
||||
val addrBookAccounts = LocalAddressBook.findAll(context, null, account).map { it.account }
|
||||
if (addrBookAccounts.any { ContentResolver.isSyncActive(it, ContactsContract.AUTHORITY) })
|
||||
return SyncStatus.ACTIVE
|
||||
|
||||
// check get pending syncs
|
||||
if (authorities.any { ContentResolver.isSyncPending(account, it) } ||
|
||||
addrBookAccounts.any { ContentResolver.isSyncPending(it, ContactsContract.AUTHORITY) })
|
||||
return SyncStatus.PENDING
|
||||
|
||||
return SyncStatus.IDLE
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests an immediate, manual sync of all available authorities for the given account.
|
||||
*
|
||||
* @param account account to sync
|
||||
*/
|
||||
fun requestSync(context: Context, account: Account) {
|
||||
for (authority in syncAuthorities(context)) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of all available sync authorities for main accounts (!= address book accounts):
|
||||
*
|
||||
* 1. address books authority (not [ContactsContract.AUTHORITY], but the one which manages address book accounts)
|
||||
* 1. calendar authority
|
||||
* 1. tasks authority (if available)
|
||||
*
|
||||
* Checking the availability of authorities may be relatively expensive, so the
|
||||
* result should be cached for the current operation.
|
||||
*
|
||||
* @return list of available sync authorities for main accounts
|
||||
*/
|
||||
fun syncAuthorities(context: Context): List<String> {
|
||||
val result = mutableListOf(
|
||||
context.getString(R.string.address_books_authority),
|
||||
CalendarContract.AUTHORITY
|
||||
)
|
||||
|
||||
TaskUtils.currentProvider(context)?.let { taskProvider ->
|
||||
result += taskProvider.authority
|
||||
}
|
||||
|
||||
return result
|
||||
return "/"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.ui.AppSettingsActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
|
||||
class ForegroundService : Service() {
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Starts/stops a foreground service, according to the app setting [Settings.FOREGROUND_SERVICE].
|
||||
*/
|
||||
const val ACTION_FOREGROUND = "foreground"
|
||||
|
||||
fun isEnabled(context: Context): Boolean {
|
||||
val settings = SettingsManager.getInstance(context)
|
||||
return settings.getBooleanOrNull(Settings.FOREGROUND_SERVICE) == true
|
||||
}
|
||||
|
||||
fun startIfEnabled(context: Context) {
|
||||
if (isEnabled(context)) {
|
||||
val serviceIntent = Intent(ACTION_FOREGROUND, null, context, ForegroundService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= 26)
|
||||
context.startForegroundService(serviceIntent)
|
||||
else
|
||||
context.startService(serviceIntent)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
override fun onBind(intent: Intent?) = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (isEnabled(this)) {
|
||||
val settingsIntent = Intent(this, AppSettingsActivity::class.java).apply {
|
||||
putExtra(AppSettingsActivity.EXTRA_SCROLL_TO, Settings.FOREGROUND_SERVICE)
|
||||
}
|
||||
val builder = NotificationCompat.Builder(this, NotificationUtils.CHANNEL_STATUS)
|
||||
.setSmallIcon(R.drawable.ic_foreground_notify)
|
||||
.setContentTitle(getString(R.string.foreground_service_notify_title))
|
||||
.setContentText(getString(R.string.foreground_service_notify_text))
|
||||
.setStyle(NotificationCompat.BigTextStyle())
|
||||
.setContentIntent(PendingIntent.getActivity(this, 0, settingsIntent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
startForeground(NotificationUtils.NOTIFY_FOREGROUND, builder.build())
|
||||
return START_STICKY
|
||||
} else {
|
||||
stopForeground(true)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,236 +6,140 @@
|
||||
* 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.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 at.bitfire.davdroid.settings.SettingsManager
|
||||
import okhttp3.*
|
||||
import okhttp3.brotli.BrotliInterceptor
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.Settings
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.tls.OkHostnameVerifier
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import java.io.File
|
||||
import java.io.Closeable
|
||||
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 {
|
||||
|
||||
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. According to [AbstractThreadedSyncAdapter], when there is no network
|
||||
// traffic within a minute, a sync will be cancelled.
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.pingInterval(45, TimeUnit.SECONDS) // avoid cancellation because of missing traffic; only works for HTTP/2
|
||||
|
||||
// keep TLS 1.0 and 1.1 for now; remove when major browsers have dropped it (probably 2020)
|
||||
.connectionSpecs(listOf(
|
||||
ConnectionSpec.CLEARTEXT,
|
||||
ConnectionSpec.COMPATIBLE_TLS
|
||||
))
|
||||
|
||||
// don't allow redirects by default, because it would break PROPFIND handling
|
||||
.followRedirects(false)
|
||||
|
||||
// offer Brotli and gzip compression
|
||||
.addInterceptor(BrotliInterceptor)
|
||||
|
||||
// add User-Agent to every request
|
||||
.addNetworkInterceptor(UserAgentInterceptor)
|
||||
|
||||
.build()
|
||||
}
|
||||
|
||||
): Closeable {
|
||||
|
||||
override fun close() {
|
||||
okHttpClient.cache?.close()
|
||||
certManager?.close()
|
||||
}
|
||||
|
||||
class Builder(
|
||||
|
||||
class Builder @JvmOverloads constructor(
|
||||
val context: Context? = null,
|
||||
accountSettings: AccountSettings? = null,
|
||||
val logger: java.util.logging.Logger = Logger.log
|
||||
logger: java.util.logging.Logger = Logger.log
|
||||
) {
|
||||
private var certManager: CustomCertManager? = null
|
||||
private var certificateAlias: String? = null
|
||||
private var cache: Cache? = null
|
||||
|
||||
private val orig = sharedClient.newBuilder()
|
||||
var certManager: CustomCertManager? = null
|
||||
private val orig = OkHttpClient.Builder()
|
||||
|
||||
init {
|
||||
// set timeouts
|
||||
orig.connectTimeout(30, TimeUnit.SECONDS)
|
||||
orig.writeTimeout(30, TimeUnit.SECONDS)
|
||||
orig.readTimeout(120, TimeUnit.SECONDS)
|
||||
|
||||
// don't allow redirects by default, because it would break PROPFIND handling
|
||||
orig.followRedirects(false)
|
||||
|
||||
// add User-Agent to every request
|
||||
orig.addNetworkInterceptor(UserAgentInterceptor)
|
||||
|
||||
// add cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
|
||||
orig.cookieJar(MemoryCookieStore())
|
||||
|
||||
// add network logging, if requested
|
||||
if (logger.isLoggable(Level.FINEST)) {
|
||||
val loggingInterceptor = HttpLoggingInterceptor { message -> logger.finest(message) }
|
||||
val loggingInterceptor = HttpLoggingInterceptor(HttpLoggingInterceptor.Logger {
|
||||
message -> logger.finest(message)
|
||||
})
|
||||
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
|
||||
orig.addInterceptor(loggingInterceptor)
|
||||
}
|
||||
|
||||
if (context != null) {
|
||||
val settings = SettingsManager.getInstance(context)
|
||||
context?.let {
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val settings = Settings(dbHelper.readableDatabase)
|
||||
|
||||
// custom proxy support
|
||||
try {
|
||||
if (settings.getBoolean(Settings.OVERRIDE_PROXY)) {
|
||||
val address = InetSocketAddress(
|
||||
settings.getString(Settings.OVERRIDE_PROXY_HOST),
|
||||
settings.getInt(Settings.OVERRIDE_PROXY_PORT)
|
||||
)
|
||||
// custom proxy support
|
||||
try {
|
||||
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)
|
||||
orig.proxy(proxy)
|
||||
Logger.log.log(Level.INFO, "Using proxy", proxy)
|
||||
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)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Can't set proxy, ignoring", e)
|
||||
}
|
||||
|
||||
// TODO don't instantiate CustomCertManager in .Builder (causes service leaks)
|
||||
customCertManager(CustomCertManager(context, true /*BuildConfig.customCertsUI*/,
|
||||
!(settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES))))
|
||||
// use cert4android to manage self-signed certificates
|
||||
if (BuildConfig.customCerts)
|
||||
customCertManager(CustomCertManager(context, !settings.getBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, false)))
|
||||
}
|
||||
}
|
||||
|
||||
// use account settings for authentication
|
||||
accountSettings?.let {
|
||||
addAuthentication(null, it.credentials())
|
||||
val userName = it.username()
|
||||
val password = it.password()
|
||||
if (userName != null && password != null)
|
||||
addAuthentication(null, userName, password)
|
||||
}
|
||||
}
|
||||
|
||||
constructor(context: Context, host: String?, credentials: Credentials): this(context) {
|
||||
addAuthentication(host, credentials)
|
||||
constructor(context: Context, account: Account): this(context, AccountSettings(context, account))
|
||||
|
||||
constructor(context: Context, host: String?, username: String, password: String): this(context) {
|
||||
addAuthentication(host, username, password)
|
||||
}
|
||||
|
||||
fun withDiskCache(context: Context): Builder {
|
||||
for (dir in arrayOf(context.externalCacheDir, context.cacheDir).filterNotNull()) {
|
||||
if (dir.exists() && dir.canWrite()) {
|
||||
val cacheDir = File(dir, "HttpClient")
|
||||
cacheDir.mkdir()
|
||||
Logger.log.fine("Using disk cache: $cacheDir")
|
||||
orig.cache(Cache(cacheDir, DISK_CACHE_MAX_SIZE))
|
||||
break
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun followRedirects(follow: Boolean): Builder {
|
||||
orig.followRedirects(follow)
|
||||
return this
|
||||
}
|
||||
fun followRedirects(follow: Boolean) { orig.followRedirects(follow) }
|
||||
|
||||
fun customCertManager(manager: CustomCertManager) {
|
||||
certManager = manager
|
||||
orig.sslSocketFactory(SSLSocketFactoryCompat(manager), manager)
|
||||
orig.hostnameVerifier(manager.hostnameVerifier(OkHostnameVerifier.INSTANCE))
|
||||
}
|
||||
fun setForeground(foreground: Boolean): Builder {
|
||||
certManager?.appInForeground = foreground
|
||||
return this
|
||||
}
|
||||
|
||||
fun addAuthentication(host: String?, credentials: Credentials): Builder {
|
||||
if (credentials.userName != null && credentials.password != null) {
|
||||
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), credentials.userName, credentials.password)
|
||||
orig .addNetworkInterceptor(authHandler)
|
||||
.authenticator(authHandler)
|
||||
}
|
||||
if (credentials.certificateAlias != null)
|
||||
certificateAlias = credentials.certificateAlias
|
||||
fun addAuthentication(host: String?, username: String, password: String): Builder {
|
||||
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), username, password)
|
||||
orig .addNetworkInterceptor(authHandler)
|
||||
.authenticator(authHandler)
|
||||
return this
|
||||
}
|
||||
|
||||
fun build(): HttpClient {
|
||||
val trustManager = certManager ?: run {
|
||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
factory.init(null as KeyStore?)
|
||||
factory.trustManagers.first() as X509TrustManager
|
||||
}
|
||||
|
||||
val hostnameVerifier = certManager?.hostnameVerifier(OkHostnameVerifier)
|
||||
?: OkHostnameVerifier
|
||||
|
||||
var keyManager: KeyManager? = null
|
||||
certificateAlias?.let { alias ->
|
||||
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 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))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fun build() = HttpClient(orig.build(), certManager)
|
||||
}
|
||||
|
||||
|
||||
private object UserAgentInterceptor: Interceptor {
|
||||
// use Locale.ROOT because numbers may be encoded as non-ASCII characters in other locales
|
||||
private val userAgentDateFormat = SimpleDateFormat("yyyy/MM/dd", Locale.ROOT)
|
||||
private val userAgentDate = userAgentDateFormat.format(Date(BuildConfig.buildTime))
|
||||
private val userAgent = "${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4jvm; " +
|
||||
"okhttp/${OkHttp.VERSION}) Android/${Build.VERSION.RELEASE}"
|
||||
|
||||
init {
|
||||
Logger.log.info("Will set \"User-Agent: $userAgent\" for further requests")
|
||||
}
|
||||
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 intercept(chain: Interceptor.Chain): Response {
|
||||
val locale = Locale.getDefault()
|
||||
|
||||
@@ -27,12 +27,12 @@ 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) {
|
||||
for (cookie in cookies)
|
||||
storage.put(cookie.name, cookie.domain, cookie.path, cookie)
|
||||
storage.put(cookie.name(), cookie.domain(), cookie.path(), cookie)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ class MemoryCookieStore: CookieJar {
|
||||
val cookie = iter.value
|
||||
|
||||
// remove expired cookies
|
||||
if (cookie.expiresAt <= System.currentTimeMillis()) {
|
||||
if (cookie.expiresAt() <= System.currentTimeMillis()) {
|
||||
iter.remove()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1,25 +1,65 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.accounts.Account
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Bundle
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.ServiceDB.Services
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
|
||||
abstract class PackageChangedReceiver(
|
||||
val context: Context
|
||||
): BroadcastReceiver(), AutoCloseable {
|
||||
class PackageChangedReceiver: BroadcastReceiver() {
|
||||
|
||||
init {
|
||||
val filter = IntentFilter(Intent.ACTION_PACKAGE_ADDED).apply {
|
||||
addAction(Intent.ACTION_PACKAGE_CHANGED)
|
||||
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||
addDataScheme("package")
|
||||
@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 {
|
||||
|
||||
@JvmStatic
|
||||
fun updateTaskSync(context: Context) {
|
||||
val tasksInstalled = LocalTaskList.tasksProviderAvailable(context)
|
||||
Logger.log.info("Package (un)installed; OpenTasks provider now available = $tasksInstalled")
|
||||
|
||||
// check all accounts and (de)activate OpenTasks if a CalDAV service is defined
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.readableDatabase
|
||||
|
||||
db.query(Services._TABLE, arrayOf(Services.ACCOUNT_NAME),
|
||||
"${Services.SERVICE}=?", arrayOf(Services.SERVICE_CALDAV), null, null, null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val account = Account(cursor.getString(0), context.getString(R.string.account_type))
|
||||
|
||||
if (tasksInstalled) {
|
||||
if (ContentResolver.getIsSyncable(account, 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)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
context.registerReceiver(this, filter)
|
||||
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
context.unregisterReceiver(this)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.Manifest
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.LocationManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.location.LocationManagerCompat
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.davdroid.ui.PermissionsActivity
|
||||
|
||||
object PermissionUtils {
|
||||
|
||||
val CONTACT_PERMISSIONS = arrayOf(
|
||||
Manifest.permission.READ_CONTACTS,
|
||||
Manifest.permission.WRITE_CONTACTS
|
||||
)
|
||||
val CALENDAR_PERMISSIONS = arrayOf(
|
||||
Manifest.permission.READ_CALENDAR,
|
||||
Manifest.permission.WRITE_CALENDAR
|
||||
)
|
||||
|
||||
val WIFI_SSID_PERMISSIONS =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_BACKGROUND_LOCATION)
|
||||
else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1)
|
||||
arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
else
|
||||
arrayOf()
|
||||
|
||||
/**
|
||||
* Checks whether all conditions to access the current WiFi's SSID are met:
|
||||
*
|
||||
* 1. location permissions ([WIFI_SSID_PERMISSIONS]) granted (Android 8.1+)
|
||||
* 2. location enabled (Android 9+)
|
||||
*
|
||||
* @return *true* if SSID can be obtained; *false* if the SSID will be <unknown> or something like that
|
||||
*/
|
||||
fun canAccessWifiSsid(context: Context): Boolean {
|
||||
// before Android 8.1, SSIDs are always readable
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1)
|
||||
return true
|
||||
|
||||
val locationAvailable =
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P)
|
||||
true // Android <9 doesn't require active location services
|
||||
else
|
||||
ContextCompat.getSystemService(context, LocationManager::class.java)?.let { locationManager ->
|
||||
LocationManagerCompat.isLocationEnabled(locationManager)
|
||||
} ?: /* location feature not available on this device */ false
|
||||
|
||||
return havePermissions(context, WIFI_SSID_PERMISSIONS) &&
|
||||
locationAvailable
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this app declares the given permission (regardless of whether it has been granted or not).
|
||||
*
|
||||
* @param permission permission to check
|
||||
*
|
||||
* @return *true* if this app declares [permission] in the manifest; *false* otherwise
|
||||
*/
|
||||
fun declaresPermission(packageManager: PackageManager, permission: String): Boolean {
|
||||
val info = packageManager.getPackageInfo(BuildConfig.APPLICATION_ID, PackageManager.GET_PERMISSIONS)
|
||||
return info.requestedPermissions.contains(permission)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether at least one of the given permissions is granted.
|
||||
*
|
||||
* @param context context to check
|
||||
* @param permissions array of permissions to check
|
||||
*
|
||||
* @return whether at least one of [permissions] is granted
|
||||
*/
|
||||
fun haveAnyPermission(context: Context, permissions: Array<String>) =
|
||||
permissions.any { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED }
|
||||
|
||||
/**
|
||||
* Checks whether all given permissions are granted.
|
||||
*
|
||||
* @param context context to check
|
||||
* @param permissions array of permissions to check
|
||||
*
|
||||
* @return whether all [permissions] are granted
|
||||
*/
|
||||
fun havePermissions(context: Context, permissions: Array<String>) =
|
||||
permissions.all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED }
|
||||
|
||||
/**
|
||||
* Shows a notification about missing permissions.
|
||||
*
|
||||
* @param context notification context
|
||||
* @param intent will be set as content Intent; if null, an Intent to launch PermissionsActivity will be used
|
||||
*/
|
||||
fun notifyPermissions(context: Context, intent: Intent?) {
|
||||
val contentIntent = intent ?: Intent(context, PermissionsActivity::class.java)
|
||||
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, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
NotificationManagerCompat.from(context)
|
||||
.notify(NotificationUtils.NOTIFY_PERMISSIONS, notify)
|
||||
}
|
||||
|
||||
fun showAppSettings(context: Context) {
|
||||
val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", BuildConfig.APPLICATION_ID, null))
|
||||
if (intent.resolveActivity(context.packageManager) != null)
|
||||
context.startActivity(intent)
|
||||
else
|
||||
Logger.log.warning("App settings Intent not resolvable")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,97 +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 android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
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.resource.TaskUtils
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class TasksWatcher(
|
||||
context: Context
|
||||
): PackageChangedReceiver(context) {
|
||||
|
||||
companion object {
|
||||
|
||||
@WorkerThread
|
||||
fun updateTaskSync(context: Context) {
|
||||
val tasksProvider = TaskUtils.currentProvider(context)
|
||||
Logger.log.info("App launched or other package (un)installed; current tasks provider = $tasksProvider")
|
||||
|
||||
var permissionsRequired = false // whether additional permissions are required
|
||||
val currentProvider by lazy { // only this provider shall be enabled (null to disable all providers)
|
||||
TaskUtils.currentProvider(context)
|
||||
}
|
||||
|
||||
// check all accounts and (de)activate task provider(s) if a CalDAV service is defined
|
||||
val db = AppDatabase.getInstance(context)
|
||||
val accountManager = AccountManager.get(context)
|
||||
for (account in accountManager.getAccountsByType(context.getString(R.string.account_type))) {
|
||||
val hasCalDAV = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) != null
|
||||
for (providerName in TaskProvider.ProviderName.values()) {
|
||||
val isSyncable = ContentResolver.getIsSyncable(account, providerName.authority) // may be -1 (unknown state)
|
||||
val shallBeSyncable = hasCalDAV && providerName == currentProvider
|
||||
if ((shallBeSyncable && isSyncable != 1) || (!shallBeSyncable && isSyncable != 0)) {
|
||||
// enable/disable sync
|
||||
setSyncable(context, account, providerName.authority, shallBeSyncable)
|
||||
|
||||
// if sync has just been enabled: check whether additional permissions are required
|
||||
if (shallBeSyncable && !PermissionUtils.havePermissions(context, providerName.permissions))
|
||||
permissionsRequired = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (permissionsRequired) {
|
||||
Logger.log.warning("Tasks synchronization is now enabled for at least one account, but permissions are not granted")
|
||||
PermissionUtils.notifyPermissions(context, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setSyncable(context: Context, account: Account, authority: String, syncable: Boolean) {
|
||||
if (syncable) {
|
||||
Logger.log.info("Enabling $authority sync for $account")
|
||||
ContentResolver.setIsSyncable(account, authority, 1)
|
||||
try {
|
||||
val settings = AccountSettings(context, account)
|
||||
val interval = settings.getSavedTasksSyncInterval() ?:
|
||||
SettingsManager.getInstance(context).getLong(Settings.DEFAULT_SYNC_INTERVAL)
|
||||
settings.setSyncInterval(authority, interval)
|
||||
} catch (e: InvalidAccountException) {
|
||||
// account has already been removed
|
||||
}
|
||||
} else {
|
||||
Logger.log.info("Disabling $authority sync for $account")
|
||||
ContentResolver.setIsSyncable(account, authority, 0)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
updateTaskSync(context)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.util.*
|
||||
|
||||
class TextTable(
|
||||
vararg val headers: String
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
fun indent(str: String, pos: Int): String =
|
||||
" ".repeat(pos) +
|
||||
str.split('\n').joinToString("\n" + " ".repeat(pos))
|
||||
|
||||
}
|
||||
|
||||
|
||||
private val lines = mutableListOf<Array<String>>()
|
||||
|
||||
fun addLine(vararg values: Any?) {
|
||||
if (values.size != headers.size)
|
||||
throw IllegalArgumentException("Table line must have ${headers.size} column(s)")
|
||||
lines += values.map {
|
||||
it?.toString() ?: "—"
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
val sb = StringBuilder()
|
||||
|
||||
val headerWidths = headers.map { it.length }
|
||||
val colWidths = Array<Int>(headers.size) { colIdx ->
|
||||
Collections.max(listOf(headerWidths[colIdx]) + lines.map { it[colIdx] }.map { it.length })
|
||||
}
|
||||
|
||||
// first line
|
||||
sb.append("\n┌")
|
||||
for (colIdx in 0 until headers.size)
|
||||
sb .append(StringUtils.repeat('─', colWidths[colIdx] + 2))
|
||||
.append(if (colIdx == headers.size - 1) '┐' else '┬')
|
||||
sb.append('\n')
|
||||
|
||||
// header
|
||||
sb.append('│')
|
||||
for (colIdx in 0 until headers.size)
|
||||
sb .append(' ')
|
||||
.append(StringUtils.rightPad(headers[colIdx], colWidths[colIdx] + 1))
|
||||
.append('│')
|
||||
sb.append('\n')
|
||||
|
||||
// separator between header and body
|
||||
sb.append('├')
|
||||
for (colIdx in 0 until headers.size) {
|
||||
sb .append(StringUtils.repeat('─', colWidths[colIdx] + 2))
|
||||
.append(if (colIdx == headers.size - 1) '┤' else '┼')
|
||||
}
|
||||
sb.append('\n')
|
||||
|
||||
// body
|
||||
for (line in lines) {
|
||||
for (colIdx in 0 until headers.size)
|
||||
sb .append("│ ")
|
||||
.append(StringUtils.rightPad(line[colIdx], colWidths[colIdx] + 1))
|
||||
sb.append("│\n")
|
||||
}
|
||||
|
||||
// last line
|
||||
sb.append("└")
|
||||
for (colIdx in 0 until headers.size) {
|
||||
sb .append("─".repeat(colWidths[colIdx] + 2))
|
||||
.append(if (colIdx == headers.size - 1) '┘' else '┴')
|
||||
}
|
||||
sb.append("\n\n")
|
||||
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,138 +8,94 @@
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
import android.app.Application
|
||||
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.preference.PreferenceManager
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
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.DebugInfoActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import org.apache.commons.lang3.time.DateFormatUtils
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import java.util.logging.FileHandler
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
object Logger : SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
object Logger {
|
||||
|
||||
private const val LOG_TO_FILE = "log_to_file"
|
||||
|
||||
val log: java.util.logging.Logger = java.util.logging.Logger.getLogger("davx5")
|
||||
|
||||
private lateinit var context: Application
|
||||
private lateinit var preferences: SharedPreferences
|
||||
@JvmField
|
||||
val log = Logger.getLogger("davdroid")!!
|
||||
|
||||
|
||||
fun initialize(app: Application) {
|
||||
context = app
|
||||
preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
preferences.registerOnSharedPreferenceChangeListener(this)
|
||||
fun reinitLogger(context: Context) {
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val settings = Settings(dbHelper.readableDatabase)
|
||||
|
||||
reinitialize()
|
||||
}
|
||||
val logToFile = settings.getBoolean(App.LOG_TO_EXTERNAL_STORAGE, false)
|
||||
val logVerbose = logToFile || Log.isLoggable(log.name, Log.DEBUG)
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
if (key == LOG_TO_FILE) {
|
||||
log.info("Logging settings changed; re-initializing logger")
|
||||
reinitialize()
|
||||
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
|
||||
rootLogger.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)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun reinitialize() {
|
||||
val logToFile = preferences.getBoolean(LOG_TO_FILE, false)
|
||||
val logVerbose = logToFile || BuildConfig.DEBUG || Log.isLoggable(log.name, Log.DEBUG)
|
||||
|
||||
log.info("Verbose logging: $logVerbose; to file: $logToFile")
|
||||
|
||||
// set logging level according to preferences
|
||||
val rootLogger = java.util.logging.Logger.getLogger("")
|
||||
rootLogger.level = if (logVerbose) Level.ALL else Level.INFO
|
||||
|
||||
// reset all handlers and add our own logcat handler
|
||||
rootLogger.useParentHandlers = false
|
||||
rootLogger.handlers.forEach { handler ->
|
||||
rootLogger.removeHandler(handler)
|
||||
if (handler is FileHandler) // gracefully close previous verbose-logging FileHandlers
|
||||
handler.close()
|
||||
}
|
||||
rootLogger.addHandler(LogcatHandler)
|
||||
|
||||
val nm = NotificationManagerCompat.from(context)
|
||||
// log to external file according to preferences
|
||||
if (logToFile) {
|
||||
val logDir = debugDir() ?: return
|
||||
val logFile = File(logDir, "davx5-log.txt")
|
||||
if (logFile.createNewFile())
|
||||
logFile.writeText("Log file created at ${Date()}; PID ${Process.myPid()}; UID ${Process.myUid()}\n")
|
||||
|
||||
try {
|
||||
val fileHandler = FileHandler(logFile.toString(), true).apply {
|
||||
formatter = PlainTextFormatter.DEFAULT
|
||||
}
|
||||
rootLogger.addHandler(fileHandler)
|
||||
log.info("Now logging to file: $logFile")
|
||||
|
||||
val builder = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_DEBUG)
|
||||
builder .setSmallIcon(R.drawable.ic_sd_card_notify)
|
||||
.setContentTitle(context.getString(R.string.app_settings_logging))
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setContentText(context.getString(R.string.logging_notification_text, context.getString(R.string.app_name)))
|
||||
.setOngoing(true)
|
||||
|
||||
val shareIntent = Intent(Intent.ACTION_SEND, null, context, DebugInfoActivity::class.java)
|
||||
shareIntent.putExtra(DebugInfoActivity.EXTRA_LOG_FILE, logFile.absolutePath)
|
||||
shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
val pendingShare = PendingIntent.getActivity(context, 0, shareIntent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
builder.addAction(NotificationCompat.Action.Builder(
|
||||
R.drawable.ic_share,
|
||||
context.getString(R.string.logging_notification_view_share),
|
||||
pendingShare
|
||||
).build())
|
||||
|
||||
val prefIntent = Intent(context, AppSettingsActivity::class.java)
|
||||
prefIntent.putExtra(AppSettingsActivity.EXTRA_SCROLL_TO, LOG_TO_FILE)
|
||||
prefIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
val pendingPref = PendingIntent.getActivity(context, 0, prefIntent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
builder.addAction(NotificationCompat.Action.Builder(
|
||||
R.drawable.ic_settings,
|
||||
context.getString(R.string.logging_notification_disable),
|
||||
pendingPref
|
||||
).build())
|
||||
|
||||
nm.notify(NotificationUtils.NOTIFY_EXTERNAL_FILE_LOGGING, builder.build())
|
||||
} catch(e: IOException) {
|
||||
log.log(Level.SEVERE, "Couldn't create log file", e)
|
||||
Toast.makeText(context, context.getString(R.string.logging_couldnt_create_file), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
} else {
|
||||
// verbose logging is disabled -> cancel notification and remove old logs
|
||||
nm.cancel(NotificationUtils.NOTIFY_EXTERNAL_FILE_LOGGING)
|
||||
debugDir()?.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun debugDir(): File? {
|
||||
val dir = File(context.filesDir, "debug")
|
||||
if (dir.exists() && dir.isDirectory)
|
||||
return dir
|
||||
|
||||
if (dir.mkdir())
|
||||
return dir
|
||||
|
||||
Toast.makeText(context, context.getString(R.string.logging_couldnt_create_file), Toast.LENGTH_LONG).show()
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
@@ -11,7 +11,6 @@ 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.*
|
||||
import java.util.logging.Formatter
|
||||
import java.util.logging.LogRecord
|
||||
|
||||
@@ -20,17 +19,17 @@ class PlainTextFormatter private constructor(
|
||||
): Formatter() {
|
||||
|
||||
companion object {
|
||||
val LOGCAT = PlainTextFormatter(true)
|
||||
val DEFAULT = PlainTextFormatter(false)
|
||||
@JvmField val LOGCAT = PlainTextFormatter(true)
|
||||
@JvmField val DEFAULT = PlainTextFormatter(false)
|
||||
|
||||
const val MAX_MESSAGE_LENGTH = 20000
|
||||
val MAX_MESSAGE_LENGTH = 20000
|
||||
}
|
||||
|
||||
override fun format(r: LogRecord): String {
|
||||
val builder = StringBuilder()
|
||||
|
||||
if (!logcat)
|
||||
builder .append(DateFormatUtils.format(r.millis, "yyyy-MM-dd HH:mm:ss", Locale.ROOT))
|
||||
builder .append(DateFormatUtils.format(r.millis, "yyyy-MM-dd HH:mm:ss"))
|
||||
.append(" ").append(r.threadID).append(" ")
|
||||
|
||||
val className = shortClassName(r.sourceClassName)
|
||||
|
||||
@@ -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,216 +0,0 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteQueryBuilder
|
||||
import androidx.core.database.getStringOrNull
|
||||
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.AndroidSingleton
|
||||
import at.bitfire.davdroid.TextTable
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import java.io.Writer
|
||||
|
||||
@Suppress("ClassName")
|
||||
@Database(entities = [
|
||||
Service::class,
|
||||
HomeSet::class,
|
||||
Collection::class,
|
||||
SyncStats::class
|
||||
], exportSchema = true, version = 9)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class AppDatabase: RoomDatabase() {
|
||||
|
||||
abstract fun serviceDao(): ServiceDao
|
||||
abstract fun homeSetDao(): HomeSetDao
|
||||
abstract fun collectionDao(): CollectionDao
|
||||
abstract fun syncStatsDao(): SyncStatsDao
|
||||
|
||||
companion object: AndroidSingleton<AppDatabase>() {
|
||||
|
||||
override fun createInstance(context: Context): AppDatabase =
|
||||
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "services.db")
|
||||
.addMigrations(*migrations)
|
||||
.fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing
|
||||
.build()
|
||||
|
||||
|
||||
// migrations
|
||||
|
||||
val migrations: Array<Migration> = arrayOf(
|
||||
object : Migration(8, 9) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("CREATE TABLE syncstats (" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"collectionId INTEGER NOT NULL REFERENCES collection(id) ON DELETE CASCADE," +
|
||||
"authority TEXT NOT NULL," +
|
||||
"lastSync INTEGER NOT NULL)")
|
||||
db.execSQL("CREATE UNIQUE INDEX index_syncstats_collectionId_authority ON syncstats(collectionId, authority)")
|
||||
|
||||
db.execSQL("CREATE INDEX index_collection_url ON collection(url)")
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(7, 8) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN personal INTEGER NOT NULL DEFAULT 1")
|
||||
db.execSQL("ALTER TABLE collection ADD COLUMN homeSetId INTEGER DEFAULT NULL REFERENCES homeset(id) ON DELETE SET NULL")
|
||||
db.execSQL("ALTER TABLE collection ADD COLUMN owner TEXT DEFAULT NULL")
|
||||
db.execSQL("CREATE INDEX index_collection_homeSetId_type ON collection(homeSetId, type)")
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(6, 7) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN privBind INTEGER NOT NULL DEFAULT 1")
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN displayName TEXT DEFAULT NULL")
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(5, 6) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
val sql = arrayOf(
|
||||
// migrate "services" to "service": rename columns, make id NOT NULL
|
||||
"CREATE TABLE service(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"accountName TEXT NOT NULL," +
|
||||
"type TEXT NOT NULL," +
|
||||
"principal TEXT DEFAULT NULL" +
|
||||
")",
|
||||
"CREATE UNIQUE INDEX index_service_accountName_type ON service(accountName, type)",
|
||||
"INSERT INTO service(id, accountName, type, principal) SELECT _id, accountName, service, principal FROM services",
|
||||
"DROP TABLE services",
|
||||
|
||||
// migrate "homesets" to "homeset": rename columns, make id NOT NULL
|
||||
"CREATE TABLE homeset(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"serviceId INTEGER NOT NULL," +
|
||||
"url TEXT NOT NULL," +
|
||||
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
|
||||
")",
|
||||
"CREATE UNIQUE INDEX index_homeset_serviceId_url ON homeset(serviceId, url)",
|
||||
"INSERT INTO homeset(id, serviceId, url) SELECT _id, serviceID, url FROM homesets",
|
||||
"DROP TABLE homesets",
|
||||
|
||||
// migrate "collections" to "collection": rename columns, make id NOT NULL
|
||||
"CREATE TABLE collection(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"serviceId INTEGER NOT NULL," +
|
||||
"type TEXT NOT NULL," +
|
||||
"url TEXT NOT NULL," +
|
||||
"privWriteContent INTEGER NOT NULL DEFAULT 1," +
|
||||
"privUnbind INTEGER NOT NULL DEFAULT 1," +
|
||||
"forceReadOnly INTEGER NOT NULL DEFAULT 0," +
|
||||
"displayName TEXT DEFAULT NULL," +
|
||||
"description TEXT DEFAULT NULL," +
|
||||
"color INTEGER DEFAULT NULL," +
|
||||
"timezone TEXT DEFAULT NULL," +
|
||||
"supportsVEVENT INTEGER DEFAULT NULL," +
|
||||
"supportsVTODO INTEGER DEFAULT NULL," +
|
||||
"supportsVJOURNAL INTEGER DEFAULT NULL," +
|
||||
"source TEXT DEFAULT NULL," +
|
||||
"sync INTEGER NOT NULL DEFAULT 0," +
|
||||
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
|
||||
")",
|
||||
"CREATE INDEX index_collection_serviceId_type ON collection(serviceId,type)",
|
||||
"INSERT INTO collection(id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync) " +
|
||||
"SELECT _id, serviceID, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync FROM collections",
|
||||
"DROP TABLE collections"
|
||||
)
|
||||
sql.forEach { db.execSQL(it) }
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(4, 5) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN privWriteContent INTEGER DEFAULT 0 NOT NULL")
|
||||
db.execSQL("UPDATE collections SET privWriteContent=NOT readOnly")
|
||||
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN privUnbind INTEGER DEFAULT 0 NOT NULL")
|
||||
db.execSQL("UPDATE collections SET privUnbind=NOT readOnly")
|
||||
|
||||
// there's no DROP COLUMN in SQLite, so just keep the "readOnly" column
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(3, 4) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN forceReadOnly INTEGER DEFAULT 0 NOT NULL")
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
// We don't have access to the context in a Room migration now, so
|
||||
// we will just drop those settings from old DAVx5 versions.
|
||||
Logger.log.warning("Dropping settings distrustSystemCerts and overrideProxy*")
|
||||
|
||||
/*val edit = PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
try {
|
||||
db.query("settings", arrayOf("setting", "value"), null, null, null, null, null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
when (cursor.getString(0)) {
|
||||
"distrustSystemCerts" -> edit.putBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, cursor.getInt(1) != 0)
|
||||
"overrideProxy" -> edit.putBoolean(App.OVERRIDE_PROXY, cursor.getInt(1) != 0)
|
||||
"overrideProxyHost" -> edit.putString(App.OVERRIDE_PROXY_HOST, cursor.getString(1))
|
||||
"overrideProxyPort" -> edit.putInt(App.OVERRIDE_PROXY_PORT, cursor.getInt(1))
|
||||
|
||||
StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED ->
|
||||
edit.putBoolean(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED, cursor.getInt(1) != 0)
|
||||
StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED ->
|
||||
edit.putBoolean(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED, cursor.getInt(1) != 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
db.execSQL("DROP TABLE settings")
|
||||
} finally {
|
||||
edit.apply()
|
||||
}*/
|
||||
}
|
||||
},
|
||||
|
||||
object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN type TEXT NOT NULL DEFAULT ''")
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN source TEXT DEFAULT NULL")
|
||||
db.execSQL("UPDATE collections SET type=(" +
|
||||
"SELECT CASE service WHEN ? THEN ? ELSE ? END " +
|
||||
"FROM services WHERE _id=collections.serviceID" +
|
||||
")",
|
||||
arrayOf("caldav", "CALENDAR", "ADDRESS_BOOK"))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
||||
fun dump(writer: Writer) {
|
||||
val db = openHelper.readableDatabase
|
||||
db.beginTransactionNonExclusive()
|
||||
|
||||
// iterate through all tables
|
||||
db.query(SQLiteQueryBuilder.buildQueryString(false, "sqlite_master", arrayOf("name"), "type='table'", null, null, null, null)).use { cursorTables ->
|
||||
while (cursorTables.moveToNext()) {
|
||||
val tableName = cursorTables.getString(0)
|
||||
|
||||
writer.append("$tableName\n")
|
||||
db.query("SELECT * FROM $tableName").use { cursor ->
|
||||
val table = TextTable(*cursor.columnNames)
|
||||
val cols = cursor.columnCount
|
||||
// print rows
|
||||
while (cursor.moveToNext()) {
|
||||
val values = Array(cols) { idx -> cursor.getStringOrNull(idx) }
|
||||
table.addLine(*values)
|
||||
}
|
||||
writer.append(table.toString())
|
||||
}
|
||||
}
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,170 +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
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
|
||||
@Entity(tableName = "collection",
|
||||
foreignKeys = [
|
||||
ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE),
|
||||
ForeignKey(entity = HomeSet::class, parentColumns = arrayOf("id"), childColumns = arrayOf("homeSetId"), onDelete = ForeignKey.SET_NULL)
|
||||
],
|
||||
indices = [
|
||||
Index("serviceId","type"),
|
||||
Index("homeSetId","type"),
|
||||
Index("url")
|
||||
]
|
||||
)
|
||||
data class Collection(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
override var id: Long = 0,
|
||||
|
||||
var serviceId: Long = 0,
|
||||
var homeSetId: Long? = null,
|
||||
|
||||
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,
|
||||
var owner: HttpUrl? = 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() {
|
||||
|
||||
@Ignore
|
||||
var refHomeSet: HomeSet? = null
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
val displayName = StringUtils.trimToNull(dav[DisplayName::class.java]?.displayName)
|
||||
val owner = dav[Owner::class.java]?.href?.let { ownerHref ->
|
||||
dav.href.resolve(ownerHref)
|
||||
}
|
||||
|
||||
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://")
|
||||
href.toHttpUrlOrNull()
|
||||
}
|
||||
}
|
||||
supportsVEVENT = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Collection(
|
||||
type = type,
|
||||
url = url,
|
||||
privWriteContent = privWriteContent,
|
||||
privUnbind = privUnbind,
|
||||
displayName = displayName,
|
||||
owner = owner,
|
||||
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,55 +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 ORDER BY displayName, url")
|
||||
fun getByServiceAndType(serviceId: Long, type: String): List<Collection>
|
||||
|
||||
/**
|
||||
* Returns collections which
|
||||
* - support VEVENT and/or VTODO (= supported calendar collections), or
|
||||
* - have supportsVEVENT = supportsVTODO = null (= address books)
|
||||
*/
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type " +
|
||||
"AND (supportsVTODO OR supportsVEVENT OR (supportsVEVENT IS NULL AND supportsVTODO IS NULL)) ORDER BY displayName, URL")
|
||||
fun pageByServiceAndType(serviceId: Long, type: String): DataSource.Factory<Int, Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND sync")
|
||||
fun getByServiceAndSync(serviceId: Long): List<Collection>
|
||||
|
||||
@Query("SELECT collection.* FROM collection, homeset WHERE collection.serviceId=:serviceId AND type=:type AND homeSetId=homeset.id AND homeset.personal ORDER BY collection.displayName, collection.url")
|
||||
fun pagePersonalByServiceAndType(serviceId: Long, type: String): DataSource.Factory<Int, Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE url=:url")
|
||||
fun getByUrl(url: String): 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 type='${Collection.TYPE_CALENDAR}' AND supportsVEVENT AND sync ORDER BY displayName, url")
|
||||
fun getSyncCalendars(serviceId: Long): List<Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' 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,159 @@
|
||||
/*
|
||||
* 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,
|
||||
|
||||
// subscriptions
|
||||
var source: String? = null,
|
||||
|
||||
// non-persistent properties
|
||||
var confirmed: Boolean = false
|
||||
): Serializable {
|
||||
|
||||
enum class Type {
|
||||
ADDRESS_BOOK,
|
||||
CALENDAR,
|
||||
WEBCAL // iCalendar subscription
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
val DAV_PROPERTIES = arrayOf(
|
||||
ResourceType.NAME,
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
DisplayName.NAME,
|
||||
AddressbookDescription.NAME, SupportedAddressData.NAME,
|
||||
CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME,
|
||||
Source.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
|
||||
type.types.contains(ResourceType.SUBSCRIBED) -> this.type = Type.WEBCAL
|
||||
}
|
||||
}
|
||||
|
||||
(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
|
||||
}
|
||||
}
|
||||
Type.WEBCAL -> {
|
||||
(dav.properties[Source.NAME] as Source?)?.let { source = it.hrefs.firstOrNull() }
|
||||
supportsVEVENT = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
constructor(values: ContentValues): this(values.getAsString(Collections.URL)) {
|
||||
id = values.getAsLong(Collections.ID)
|
||||
serviceID = values.getAsLong(Collections.SERVICE_ID)
|
||||
type = try {
|
||||
Type.valueOf(values.getAsString(Collections.TYPE))
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
source = values.getAsString(Collections.SOURCE)
|
||||
|
||||
selected = values.getAsInteger(Collections.SYNC) != 0
|
||||
}
|
||||
|
||||
fun toDB(): ContentValues {
|
||||
val values = ContentValues()
|
||||
// Collections.SERVICE_ID is never changed
|
||||
type?.let { values.put(Collections.TYPE, it.name) }
|
||||
|
||||
values.put(Collections.URL, url)
|
||||
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.SOURCE, source)
|
||||
|
||||
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,17 +0,0 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
class Converters {
|
||||
|
||||
@TypeConverter
|
||||
fun httpUrlToString(url: HttpUrl?) =
|
||||
url?.toString()
|
||||
|
||||
@TypeConverter
|
||||
fun stringToHttpUrl(url: String?): HttpUrl? =
|
||||
url?.toHttpUrlOrNull()
|
||||
|
||||
}
|
||||
@@ -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.model
|
||||
|
||||
class Credentials(
|
||||
val userName: String? = null,
|
||||
val password: String? = null,
|
||||
val certificateAlias: String? = null
|
||||
) {
|
||||
|
||||
override fun toString(): String {
|
||||
val maskedPassword = "*****".takeIf { password != null }
|
||||
return "Credentials(userName=$userName, password=$maskedPassword, certificateAlias=$certificateAlias)"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,46 +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 {
|
||||
|
||||
/**
|
||||
* Synchronizes a list of "old" elements with a list of "new" elements so that the list
|
||||
* only contain equal elements.
|
||||
*
|
||||
* @param allOld list of old elements
|
||||
* @param allNew map of new elements (stored in key map)
|
||||
* @param selectKey generates a unique key from the element (will be called on old elements)
|
||||
* @param prepareNew prepares new elements (can be used to take over properties of old elements)
|
||||
*/
|
||||
fun <K> syncAll(allOld: List<T>, allNew: Map<K,T>, selectKey: (T) -> K, prepareNew: (new: T, old: T) -> Unit = { _, _ -> }) {
|
||||
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
|
||||
prepareNew(matchingNew, old)
|
||||
|
||||
if (matchingNew != old)
|
||||
update(matchingNew)
|
||||
|
||||
// remove from remainingNew
|
||||
remainingNew -= key
|
||||
} else {
|
||||
// this old item is not present anymore, delete it
|
||||
delete(old)
|
||||
}
|
||||
}
|
||||
|
||||
val toInsert = remainingNew.values.toList()
|
||||
val insertIds = insert(toInsert)
|
||||
insertIds.withIndex().forEach { (idx, id) ->
|
||||
toInsert[idx].id = id
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,34 +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,
|
||||
|
||||
/**
|
||||
* Whether this homeset belongs to the [Service.principal] given by [serviceId].
|
||||
*/
|
||||
var personal: Boolean,
|
||||
|
||||
var url: HttpUrl,
|
||||
|
||||
var privBind: Boolean = true,
|
||||
|
||||
var displayName: String? = null
|
||||
): IdEntity()
|
||||
@@ -1,24 +0,0 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
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>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM homeset WHERE serviceId=:serviceId AND privBind")
|
||||
fun hasBindableByService(serviceId: Long): LiveData<Boolean>
|
||||
|
||||
@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,189 @@
|
||||
/*
|
||||
* 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 TYPE = "type"
|
||||
@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 SOURCE = "source"
|
||||
@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 = 2
|
||||
}
|
||||
|
||||
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.TYPE} TEXT NOT NULL," +
|
||||
"${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.SOURCE} TEXT NULL," +
|
||||
"${Collections.SYNC} INTEGER DEFAULT 0 NOT NULL)")
|
||||
db.execSQL("CREATE UNIQUE INDEX collections_service_url ON ${Collections._TABLE}(${Collections.SERVICE_ID},${Collections.URL})")
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
if (oldVersion == 1 && newVersion == 2) {
|
||||
// the only possible migration at the moment
|
||||
db.execSQL("ALTER TABLE ${Collections._TABLE} ADD COLUMN ${Collections.TYPE} TEXT NOT NULL DEFAULT ''")
|
||||
db.execSQL("ALTER TABLE ${Collections._TABLE} ADD COLUMN ${Collections.SOURCE} TEXT NULL")
|
||||
db.execSQL("UPDATE ${Collections._TABLE} SET ${Collections.TYPE}=(" +
|
||||
"SELECT CASE ${Services.SERVICE} WHEN ? THEN ? ELSE ? END " +
|
||||
"FROM ${Services._TABLE} WHERE ${Services.ID}=${Collections._TABLE}.${Collections.SERVICE_ID}" +
|
||||
")",
|
||||
arrayOf(Services.SERVICE_CALDAV, CollectionInfo.Type.CALENDAR, CollectionInfo.Type.ADDRESS_BOOK))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun dump(sb: StringBuilder) {
|
||||
val db = readableDatabase
|
||||
db.beginTransactionNonExclusive()
|
||||
|
||||
// iterate through all tables
|
||||
db.query("sqlite_master", arrayOf("name"), "type='table'", null, null, null, null).use { cursorTables ->
|
||||
while (cursorTables.moveToNext()) {
|
||||
val table = cursorTables.getString(0)
|
||||
sb.append(table).append("\n")
|
||||
db.query(table, null, null, null, null, null, null).use { cursor ->
|
||||
// print columns
|
||||
val cols = cursor.columnCount
|
||||
sb.append("\t| ")
|
||||
for (i in 0 .. 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,33 +0,0 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
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): LiveData<Long>
|
||||
|
||||
@Query("SELECT * FROM service WHERE id=:id")
|
||||
fun get(id: Long): 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,108 @@
|
||||
/*
|
||||
* 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.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 getLong(name: String, defaultValue: Long): Long {
|
||||
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.getLong(0)
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
fun putLong(name: String, value: Long?) {
|
||||
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)")
|
||||
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,24 +0,0 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "syncstats",
|
||||
foreignKeys = [
|
||||
ForeignKey(childColumns = arrayOf("collectionId"), entity = Collection::class, parentColumns = arrayOf("id"), onDelete = ForeignKey.CASCADE)
|
||||
],
|
||||
indices = [
|
||||
Index("collectionId", "authority", unique = true),
|
||||
]
|
||||
)
|
||||
data class SyncStats(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Long,
|
||||
|
||||
val collectionId: Long,
|
||||
val authority: String,
|
||||
|
||||
var lastSync: Long
|
||||
)
|
||||
@@ -1,13 +0,0 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
|
||||
@Dao
|
||||
interface SyncStatsDao {
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertOrReplace(syncStats: SyncStats)
|
||||
|
||||
}
|
||||
@@ -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>): LongArray
|
||||
|
||||
@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()
|
||||
|
||||
}
|
||||
@@ -21,81 +21,71 @@ import android.util.Base64
|
||||
import at.bitfire.davdroid.DavUtils
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.davdroid.syncadapter.AccountUtils
|
||||
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_READ_ONLY = "read_only"
|
||||
val USER_DATA_CTAG = "ctag"
|
||||
|
||||
@JvmStatic
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, info: CollectionInfo): LocalAddressBook {
|
||||
val accountManager = AccountManager.get(context)
|
||||
|
||||
fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, info: Collection): LocalAddressBook {
|
||||
val account = Account(accountName(mainAccount, info), context.getString(R.string.account_type_address_book))
|
||||
val userData = initialUserData(mainAccount, info.url.toString())
|
||||
Logger.log.log(Level.INFO, "Creating local address book $account", userData)
|
||||
if (!AccountUtils.createAccount(context, account, userData))
|
||||
throw IllegalStateException("Couldn't create address book account")
|
||||
if (!accountManager.addAccountExplicitly(account, null, initialUserData(mainAccount, info.url)))
|
||||
throw ContactsStorageException("Couldn't create address book account")
|
||||
|
||||
val addressBook = LocalAddressBook(context, account, provider)
|
||||
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1)
|
||||
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 {
|
||||
try {
|
||||
it.mainAccount == mainAccount
|
||||
} catch(e: IllegalStateException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
.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(context.getString(R.string.account_type_address_book))
|
||||
.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)
|
||||
@@ -104,239 +94,180 @@ 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 tag: String
|
||||
get() = "contacts-${account.name}"
|
||||
|
||||
override val title = account.name!!
|
||||
|
||||
/**
|
||||
* Whether contact groups ([LocalGroup]) are included in query results
|
||||
* and are affected by updates/deletes on generic members.
|
||||
*
|
||||
* For instance, if 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
|
||||
/**
|
||||
* The associated main account which this address book accounts belongs to.
|
||||
* @throws IllegalStateException when no main account is assigned
|
||||
*/
|
||||
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, "NOT ${Groups.DIRTY}", null)
|
||||
}
|
||||
|
||||
return number
|
||||
}
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int): Int {
|
||||
var number = provider!!.delete(rawContactsSyncUri(),
|
||||
"NOT ${RawContacts.DIRTY} AND ${LocalContact.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
|
||||
|
||||
if (includeGroups)
|
||||
number += provider.delete(groupsSyncUri(),
|
||||
"NOT ${Groups.DIRTY} AND ${LocalGroup.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
|
||||
|
||||
return number
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
Constants.log.info("Address book read-only? = ${info.readOnly}")
|
||||
setReadOnly(info.readOnly)
|
||||
|
||||
// make sure it will still be synchronized when contacts are updated
|
||||
if (ContentResolver.getIsSyncable(account, ContactsContract.AUTHORITY) <= 0)
|
||||
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1)
|
||||
if (!ContentResolver.getSyncAutomatically(account, ContactsContract.AUTHORITY))
|
||||
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
|
||||
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, null)
|
||||
fun findDeletedGroups() = queryGroups(Groups.DELETED, null)
|
||||
|
||||
/**
|
||||
* Returns an array of local contacts/groups which have been changed locally (DIRTY != 0).
|
||||
* @throws RemoteException on content provider errors
|
||||
*/
|
||||
override fun findDirty() =
|
||||
if (includeGroups)
|
||||
findDirtyContacts() + findDirtyGroups()
|
||||
else
|
||||
findDirtyContacts()
|
||||
fun findDirtyContacts() = queryContacts(RawContacts.DIRTY, null)
|
||||
fun findDirtyGroups() = queryGroups(Groups.DIRTY, null)
|
||||
|
||||
override fun forgetETags() {
|
||||
if (includeGroups) {
|
||||
val values = ContentValues(1)
|
||||
values.putNull(AndroidGroup.COLUMN_ETAG)
|
||||
provider!!.update(groupsSyncUri(), values, null, null)
|
||||
}
|
||||
val values = ContentValues(1)
|
||||
values.putNull(AndroidContact.COLUMN_ETAG)
|
||||
provider!!.update(rawContactsSyncUri(), values, null, null)
|
||||
@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.
|
||||
* if they're "really dirty" (= data has changed, not only metadata, which is not hashed).
|
||||
* The DIRTY flag is removed from contacts which are not "really dirty", i.e. from contacts
|
||||
* whose contact data checksum has not changed.
|
||||
* @return number of "really dirty" contacts
|
||||
* @throws RemoteException on content provider errors
|
||||
*/
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -345,23 +276,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 */
|
||||
@@ -371,4 +308,52 @@ 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() =
|
||||
AccountManager.get(context).getUserData(account, USER_DATA_URL) ?: throw ContactsStorageException("Address book has no URL")
|
||||
|
||||
fun setURL(url: String) =
|
||||
AccountManager.get(context).setUserData(account, USER_DATA_URL, url)
|
||||
|
||||
fun getReadOnly() =
|
||||
AccountManager.get(context).getUserData(account, USER_DATA_READ_ONLY) != null
|
||||
|
||||
fun setReadOnly(readOnly: Boolean) =
|
||||
AccountManager.get(context).setUserData(account, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
|
||||
|
||||
override fun getCTag(): String? =
|
||||
AccountManager.get(context).getUserData(account, USER_DATA_CTAG)
|
||||
|
||||
override fun setCTag(cTag: String?) =
|
||||
AccountManager.get(context).setUserData(account, USER_DATA_CTAG, cTag)
|
||||
|
||||
}
|
||||
@@ -10,20 +10,18 @@ package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
import android.provider.CalendarContract.Calendars
|
||||
import android.provider.CalendarContract.Events
|
||||
import at.bitfire.davdroid.Constants
|
||||
import android.os.RemoteException
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.*
|
||||
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,41 +33,51 @@ 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.
|
||||
values.put(Calendars.ACCOUNT_NAME, account.name)
|
||||
values.put(Calendars.ACCOUNT_TYPE, account.type)
|
||||
|
||||
// Email address for scheduling. Used by the calendar provider to determine whether the
|
||||
// user is ORGANIZER/ATTENDEE for a certain event.
|
||||
values.put(Calendars.OWNER_ACCOUNT, account.name)
|
||||
|
||||
// flag as visible & synchronizable at creation, might be changed by user at any time
|
||||
values.put(Calendars.VISIBLE, 1)
|
||||
values.put(Calendars.SYNC_EVENTS, 1)
|
||||
return create(account, provider, values)
|
||||
val uri = create(account, provider, values)
|
||||
|
||||
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 ->
|
||||
@@ -79,174 +87,143 @@ class LocalCalendar private constructor(
|
||||
Logger.log.log(Level.WARNING, "Couldn't parse calendar default time zone", e)
|
||||
}
|
||||
}
|
||||
|
||||
// add base values for Calendars
|
||||
values.putAll(calendarBaseValues)
|
||||
|
||||
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, "${CalendarContract.Attendees.TYPE_OPTIONAL},${CalendarContract.Attendees.TYPE_REQUIRED},${CalendarContract.Attendees.TYPE_RESOURCE}")
|
||||
return values
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override val tag: String
|
||||
get() = "events-${account.name}-$id"
|
||||
|
||||
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() =
|
||||
queryEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
|
||||
@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>()
|
||||
|
||||
/*
|
||||
* RFC 5545 3.8.7.4. Sequence Number
|
||||
* When a calendar component is created, its sequence number is 0. It is monotonically incremented by the "Organizer's"
|
||||
* CUA each time the "Organizer" makes a significant revision to the calendar component.
|
||||
*/
|
||||
for (localEvent in queryEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null)) {
|
||||
try {
|
||||
val event = requireNotNull(localEvent.event)
|
||||
|
||||
val nonGroupScheduled = event.attendees.isEmpty()
|
||||
val weAreOrganizer = localEvent.weAreOrganizer
|
||||
|
||||
val sequence = event.sequence
|
||||
if (sequence == null)
|
||||
// sequence has not been assigned yet (i.e. this event was just locally created)
|
||||
event.sequence = 0
|
||||
else if (nonGroupScheduled || weAreOrganizer) // increase sequence
|
||||
event.sequence = sequence + 1
|
||||
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't check/increase sequence", e)
|
||||
}
|
||||
// get dirty events which are required to have an increased SEQUENCE value
|
||||
for (localEvent in queryEvents("${Events.DIRTY}!=0 AND ${Events.ORIGINAL_ID} IS NULL", null)) {
|
||||
val event = localEvent.event!!
|
||||
val sequence = event.sequence
|
||||
if (event.sequence == null) // sequence has not been assigned yet (i.e. this event was just locally created)
|
||||
event.sequence = 0
|
||||
else if (localEvent.weAreOrganizer)
|
||||
event.sequence = sequence!! + 1
|
||||
dirty += localEvent
|
||||
}
|
||||
|
||||
return dirty
|
||||
}
|
||||
|
||||
override fun findByName(name: String) =
|
||||
queryEvents("${Events._SYNC_ID}=?", arrayOf(name)).firstOrNull()
|
||||
|
||||
|
||||
override fun markNotDirty(flags: Int): Int {
|
||||
val values = ContentValues(1)
|
||||
values.put(LocalEvent.COLUMN_FLAGS, flags)
|
||||
return provider.update(eventsSyncURI(), values,
|
||||
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int): Int {
|
||||
var deleted = 0
|
||||
// list all non-dirty events with the given flags and delete every row + its exceptions
|
||||
provider.query(eventsSyncURI(), arrayOf(Events._ID),
|
||||
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL AND ${LocalEvent.COLUMN_FLAGS}=?",
|
||||
arrayOf(id.toString(), flags.toString()), null)?.use { cursor ->
|
||||
val batch = BatchOperation(provider)
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(0)
|
||||
// delete event and possible exceptions (content provider doesn't delete exceptions itself)
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newDelete(eventsSyncURI())
|
||||
.withSelection("${Events._ID}=? OR ${Events.ORIGINAL_ID}=?", arrayOf(id.toString(), id.toString())))
|
||||
@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)
|
||||
}
|
||||
deleted = batch.commit()
|
||||
null
|
||||
} catch (e: RemoteException) {
|
||||
throw CalendarStorageException("Couldn't read local (last known) CTag", e)
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
|
||||
override fun forgetETags() {
|
||||
val values = ContentValues(1)
|
||||
values.putNull(LocalEvent.COLUMN_ETAG)
|
||||
provider.update(eventsSyncURI(), values, "${Events.CALENDAR_ID}=?",
|
||||
arrayOf(id.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} 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
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
|
||||
.withValue(LocalEvent.COLUMN_SEQUENCE, originalSequence + 1)
|
||||
.withValue(Events.DIRTY, 1))
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
|
||||
.withValue(LocalEvent.COLUMN_SEQUENCE, originalSequence + 1)
|
||||
.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.CpoBuilder.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} 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.CpoBuilder
|
||||
.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
|
||||
.withValue(Events.DIRTY, 1))
|
||||
// increase SEQUENCE and set DIRTY to 0
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,4 +235,4 @@ class LocalCalendar private constructor(
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -8,70 +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> {
|
||||
|
||||
/** a tag that uniquely identifies the collection (DAVx5-wide) */
|
||||
val tag: String
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun getDeleted(): List<T>
|
||||
|
||||
/** collection title (used for user notifications etc.) **/
|
||||
val title: String
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun getWithoutFileName(): List<T>
|
||||
|
||||
var lastSyncState: SyncState?
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class, FileNotFoundException::class)
|
||||
fun getDirty(): List<T>
|
||||
|
||||
/**
|
||||
* Finds local resources of this collection which have been marked as *deleted* by the user
|
||||
* or an app acting on their behalf.
|
||||
*
|
||||
* @return list of resources marked as *deleted*
|
||||
*/
|
||||
fun findDeleted(): List<T>
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun getAll(): List<T>
|
||||
|
||||
/**
|
||||
* Finds local resources of this collection which have been marked as *dirty*, i.e. resources
|
||||
* which have been modified by the user or an app acting on their behalf.
|
||||
*
|
||||
* @return list of resources marked as *dirty*
|
||||
*/
|
||||
fun findDirty(): List<T>
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun getCTag(): String?
|
||||
|
||||
/**
|
||||
* Finds a local resource of this collection with a given file name. (File names are assigned
|
||||
* by the sync adapter.)
|
||||
*
|
||||
* @param name file name to look for
|
||||
* @return resource with the given name, or null if none
|
||||
*/
|
||||
fun findByName(name: String): T?
|
||||
|
||||
|
||||
/**
|
||||
* Sets the [LocalEvent.COLUMN_FLAGS] value for entries which are not dirty ([Events.DIRTY] is 0)
|
||||
* and have an [Events.ORIGINAL_ID] of null.
|
||||
*
|
||||
* @param flags value of flags to set (for instance, [LocalResource.FLAG_REMOTELY_PRESENT]])
|
||||
*
|
||||
* @return number of marked entries
|
||||
*/
|
||||
fun markNotDirty(flags: Int): Int
|
||||
|
||||
/**
|
||||
* Removes entries which are not dirty ([Events.DIRTY] is 0 and an [Events.ORIGINAL_ID] is null) with
|
||||
* a given flag combination.
|
||||
*
|
||||
* @param flags exact flags value to remove entries with (for instance, if this is [LocalResource.FLAG_REMOTELY_PRESENT]],
|
||||
* all entries with exactly this flag will be removed)
|
||||
*
|
||||
* @return number of removed entries
|
||||
*/
|
||||
fun removeNotDirtyMarked(flags: Int): Int
|
||||
|
||||
|
||||
/**
|
||||
* Forgets the ETags of all members so that they will be reloaded from the server during sync.
|
||||
*/
|
||||
fun forgetETags()
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun setCTag(cTag: String?)
|
||||
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentValues
|
||||
import android.os.Build
|
||||
import android.os.RemoteException
|
||||
@@ -19,127 +20,132 @@ import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.UnknownProperties
|
||||
import at.bitfire.vcard4android.*
|
||||
import ezvcard.Ezvcard
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
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 scheduleTag: String?
|
||||
get() = null
|
||||
set(_) = throw NotImplementedError()
|
||||
|
||||
override var flags: Int = 0
|
||||
constructor(addressBook: AndroidAddressBook<LocalContact,*>, id: Long, fileName: String?, eTag: String?):
|
||||
super(addressBook, id, fileName, eTag)
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<LocalContact,*>, values: ContentValues)
|
||||
: super(addressBook, values) {
|
||||
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
|
||||
}
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<LocalContact,*>, contact: Contact, fileName: String?, eTag: String?, flags: Int)
|
||||
: super(addressBook, contact, fileName, eTag) {
|
||||
this.flags = flags
|
||||
}
|
||||
constructor(addressBook: AndroidAddressBook<LocalContact,*>, contact: Contact, fileName: String?, eTag: String?):
|
||||
super(addressBook, contact, fileName, eTag)
|
||||
|
||||
|
||||
override fun prepareForUpload(): String {
|
||||
var uid: String? = null
|
||||
addressBook.provider!!.query(rawContactSyncURI(), arrayOf(COLUMN_UID), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
uid = StringUtils.trimToNull(cursor.getString(0))
|
||||
}
|
||||
|
||||
if (uid == null) {
|
||||
// generate new UID
|
||||
uid = UUID.randomUUID().toString()
|
||||
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_UID, uid)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
contact!!.uid = uid
|
||||
}
|
||||
|
||||
return "$uid.vcf"
|
||||
}
|
||||
|
||||
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
|
||||
if (scheduleTag != null)
|
||||
throw IllegalArgumentException("Contacts must not have a Schedule-Tag")
|
||||
|
||||
val values = ContentValues(4)
|
||||
if (fileName != null)
|
||||
values.put(COLUMN_FILENAME, fileName)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(ContactsContract.RawContacts.DIRTY, 0)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.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)
|
||||
|
||||
if (fileName != null)
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun resetDeleted() {
|
||||
val values = ContentValues(1)
|
||||
values.put(ContactsContract.Groups.DELETED, 0)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
values.put(ContactsContract.RawContacts.DELETED, 0)
|
||||
try {
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't clear deleted flag", e)
|
||||
}
|
||||
}
|
||||
|
||||
@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 builder = insertDataBuilder(UnknownProperties.RAW_CONTACT_ID)
|
||||
.withValue(UnknownProperties.MIMETYPE, UnknownProperties.CONTENT_ITEM_TYPE)
|
||||
.withValue(UnknownProperties.UNKNOWN_PROPERTIES, unknownProperties)
|
||||
batch.enqueue(builder)
|
||||
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)
|
||||
}
|
||||
} catch(e: FileNotFoundException) {
|
||||
throw ContactsStorageException("Couldn't insert data rows", e)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +154,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")
|
||||
@@ -162,71 +169,88 @@ 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 hashCode = dataHashCode()
|
||||
Logger.log.fine("Storing contact hash = $hashCode")
|
||||
|
||||
if (batch == null) {
|
||||
val values = ContentValues(1)
|
||||
val values = ContentValues(1)
|
||||
try {
|
||||
val hashCode = dataHashCode()
|
||||
Logger.log.fine("Storing contact hash = $hashCode")
|
||||
values.put(COLUMN_HASHCODE, hashCode)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
} else
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newUpdate(rawContactSyncURI())
|
||||
.withValue(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))
|
||||
}
|
||||
} 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
|
||||
}
|
||||
|
||||
|
||||
fun addToGroup(batch: BatchOperation, groupID: Long) {
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newInsert(dataSyncURI())
|
||||
.withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
|
||||
.withValue(GroupMembership.RAW_CONTACT_ID, id)
|
||||
.withValue(GroupMembership.GROUP_ROW_ID, groupID))
|
||||
groupMemberships += groupID
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newInsert(dataSyncURI())
|
||||
.withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
|
||||
.withValue(GroupMembership.RAW_CONTACT_ID, id)
|
||||
.withValue(GroupMembership.GROUP_ROW_ID, groupID)
|
||||
))
|
||||
groupMemberships.add(groupID)
|
||||
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newInsert(dataSyncURI())
|
||||
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
.withValue(CachedGroupMembership.RAW_CONTACT_ID, id)
|
||||
.withValue(CachedGroupMembership.GROUP_ID, groupID)
|
||||
)
|
||||
cachedGroupMemberships += groupID
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newInsert(dataSyncURI())
|
||||
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
.withValue(CachedGroupMembership.RAW_CONTACT_ID, id)
|
||||
.withValue(CachedGroupMembership.GROUP_ID, groupID)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
cachedGroupMemberships.add(groupID)
|
||||
}
|
||||
|
||||
fun removeGroupMemberships(batch: BatchOperation) {
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newDelete(dataSyncURI())
|
||||
.withSelection(
|
||||
"${Data.RAW_CONTACT_ID}=? AND ${Data.MIMETYPE} IN (?,?)",
|
||||
arrayOf(id.toString(), GroupMembership.CONTENT_ITEM_TYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
))
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newDelete(dataSyncURI())
|
||||
.withSelection(
|
||||
Data.RAW_CONTACT_ID + "=? AND " + Data.MIMETYPE + " IN (?,?)",
|
||||
arrayOf(id.toString(), GroupMembership.CONTENT_ITEM_TYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
groupMemberships.clear()
|
||||
cachedGroupMemberships.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the IDs of all groups the contact was member of (cached memberships).
|
||||
* Cached memberships are kept in sync with memberships by DAVx5 and are used to determine
|
||||
* 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
|
||||
@@ -235,26 +259,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: BatchOperation.CpoBuilder, update: Boolean) {
|
||||
builder.withValue(COLUMN_FLAGS, flags)
|
||||
super.buildContact(builder, update)
|
||||
}
|
||||
|
||||
// factory
|
||||
|
||||
object Factory: AndroidContactFactory<LocalContact> {
|
||||
override fun fromProvider(addressBook: AndroidAddressBook<LocalContact, *>, values: ContentValues) =
|
||||
LocalContact(addressBook, 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)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,138 +8,141 @@
|
||||
|
||||
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 org.apache.commons.lang3.StringUtils
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.*
|
||||
|
||||
class LocalEvent: AndroidEvent, LocalResource<Event> {
|
||||
class LocalEvent: AndroidEvent, LocalResource {
|
||||
|
||||
companion object {
|
||||
init {
|
||||
ICalendar.prodId = ProdId("${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ical4j/" + Ical4Android.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
|
||||
const val COLUMN_SCHEDULE_TAG = Events.SYNC_DATA4
|
||||
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 scheduleTag: String? = null
|
||||
|
||||
override var flags: Int = 0
|
||||
private set
|
||||
|
||||
var weAreOrganizer = false
|
||||
private set
|
||||
var weAreOrganizer = true
|
||||
|
||||
|
||||
constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?, scheduleTag: 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.scheduleTag = scheduleTag
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
private constructor(calendar: AndroidCalendar<*>, values: ContentValues): super(calendar, values) {
|
||||
fileName = values.getAsString(Events._SYNC_ID)
|
||||
eTag = values.getAsString(COLUMN_ETAG)
|
||||
scheduleTag = values.getAsString(COLUMN_SCHEDULE_TAG)
|
||||
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
override fun populateEvent(row: ContentValues, groupScheduled: Boolean) {
|
||||
|
||||
/* 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)
|
||||
fileName = row.getAsString(Events._SYNC_ID)
|
||||
eTag = row.getAsString(COLUMN_ETAG)
|
||||
event.uid = row.getAsString(COLUMN_UID)
|
||||
|
||||
event.sequence = row.getAsInteger(COLUMN_SEQUENCE)
|
||||
|
||||
val isOrganizer = row.getAsInteger(Events.IS_ORGANIZER)
|
||||
weAreOrganizer = isOrganizer != null && isOrganizer != 0
|
||||
|
||||
super.populateEvent(row, groupScheduled)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
override fun buildEvent(recurrence: Event?, builder: BatchOperation.CpoBuilder) {
|
||||
@Throws(FileNotFoundException::class, CalendarStorageException::class)
|
||||
override fun buildEvent(recurrence: Event?, builder: ContentProviderOperation.Builder) {
|
||||
super.buildEvent(recurrence, builder)
|
||||
val event = requireNotNull(event)
|
||||
|
||||
val buildException = recurrence != null
|
||||
val eventToBuild = recurrence ?: event
|
||||
|
||||
builder .withValue(Events.UID_2445, event.uid)
|
||||
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)
|
||||
else
|
||||
builder .withValue(Events._SYNC_ID, fileName)
|
||||
.withValue(COLUMN_ETAG, eTag)
|
||||
.withValue(COLUMN_SCHEDULE_TAG, scheduleTag)
|
||||
|
||||
super.buildEvent(recurrence, builder)
|
||||
}
|
||||
|
||||
|
||||
override fun prepareForUpload(): String {
|
||||
var uid: String? = null
|
||||
calendar.provider.query(eventSyncURI(), arrayOf(Events.UID_2445), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
uid = StringUtils.trimToNull(cursor.getString(0))
|
||||
}
|
||||
/* custom queries */
|
||||
|
||||
if (uid == null) {
|
||||
// generate new UID
|
||||
uid = UUID.randomUUID().toString()
|
||||
@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 values = ContentValues(1)
|
||||
values.put(Events.UID_2445, uid)
|
||||
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)
|
||||
}
|
||||
|
||||
return "$uid.ics"
|
||||
}
|
||||
|
||||
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
|
||||
val values = ContentValues(5)
|
||||
if (fileName != null)
|
||||
values.put(Events._SYNC_ID, fileName)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(COLUMN_SCHEDULE_TAG, scheduleTag)
|
||||
values.put(COLUMN_SEQUENCE, event!!.sequence)
|
||||
values.put(Events.DIRTY, 0)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun clearDirty(eTag: String?) {
|
||||
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)
|
||||
|
||||
if (fileName != null)
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.scheduleTag = scheduleTag
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
this.flags = flags
|
||||
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)
|
||||
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
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
|
||||
@@ -19,107 +19,168 @@ 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.dav4android.Constants
|
||||
import at.bitfire.vcard4android.*
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
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
|
||||
|
||||
/** marshalled list of member UIDs, as sent by server */
|
||||
const val COLUMN_PENDING_MEMBERS = Groups.SYNC3
|
||||
/** marshaled list of member UIDs, as sent by server */
|
||||
val COLUMN_PENDING_MEMBERS = Groups.SYNC3
|
||||
|
||||
/**
|
||||
* Processes all groups with non-null [COLUMN_PENDING_MEMBERS]: the pending memberships
|
||||
* are applied (if possible) to keep cached memberships in sync.
|
||||
*
|
||||
* 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.CpoBuilder
|
||||
.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, id)))
|
||||
.withValue(COLUMN_PENDING_MEMBERS, null))
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't get pending memberships", e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
override var scheduleTag: String?
|
||||
get() = null
|
||||
set(value) = throw NotImplementedError()
|
||||
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, id: Long, fileName: String?, eTag: String?):
|
||||
super(addressBook, id, fileName, eTag)
|
||||
|
||||
override var flags: Int = 0
|
||||
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
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun resetDeleted() {
|
||||
val uri = ContentUris.withAppendedId(addressBook.syncAdapterURI(ContactsContract.Groups.CONTENT_URI), requireNotNull(id))
|
||||
|
||||
val values = ContentValues(1)
|
||||
values.put(ContactsContract.Groups.DELETED, 0)
|
||||
try {
|
||||
addressBook.provider!!.update(uri, values, null, null)
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't clear deleted flag", e)
|
||||
}
|
||||
}
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, contact: Contact, fileName: String?, eTag: String?, flags: Int)
|
||||
: super(addressBook, contact, fileName, eTag) {
|
||||
this.flags = flags
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun clearDirty(eTag: String?) {
|
||||
val id = requireNotNull(id)
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(Groups.DIRTY, 0)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
this.eTag = eTag
|
||||
update(values)
|
||||
|
||||
// update cached group memberships
|
||||
val batch = BatchOperation(addressBook.provider!!)
|
||||
|
||||
// delete cached group memberships
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newDelete(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
|
||||
.withSelection(
|
||||
CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?",
|
||||
arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE, id.toString())
|
||||
)
|
||||
))
|
||||
|
||||
// insert updated cached group memberships
|
||||
for (member in getMembers())
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newInsert(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
|
||||
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
.withValue(CachedGroupMembership.RAW_CONTACT_ID, member)
|
||||
.withValue(CachedGroupMembership.GROUP_ID, id)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
|
||||
@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()
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
|
||||
val members = Parcel.obtain()
|
||||
try {
|
||||
@@ -132,118 +193,48 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
||||
}
|
||||
|
||||
|
||||
override fun prepareForUpload(): String {
|
||||
var uid: String? = null
|
||||
addressBook.provider!!.query(groupSyncUri(), arrayOf(AndroidContact.COLUMN_UID), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
uid = StringUtils.trimToNull(cursor.getString(0))
|
||||
}
|
||||
|
||||
if (uid == null) {
|
||||
// generate new UID
|
||||
uid = UUID.randomUUID().toString()
|
||||
|
||||
val values = ContentValues(1)
|
||||
values.put(AndroidContact.COLUMN_UID, uid)
|
||||
addressBook.provider!!.update(groupSyncUri(), values, null, null)
|
||||
|
||||
contact!!.uid = uid
|
||||
}
|
||||
|
||||
return "$uid.vcf"
|
||||
}
|
||||
|
||||
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
|
||||
if (scheduleTag != null)
|
||||
throw IllegalArgumentException("Contact groups must not have a Schedule-Tag")
|
||||
val id = requireNotNull(id)
|
||||
|
||||
val values = ContentValues(3)
|
||||
if (fileName != null)
|
||||
values.put(COLUMN_FILENAME, fileName)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(Groups.DIRTY, 0)
|
||||
update(values)
|
||||
|
||||
if (fileName != null)
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
|
||||
// update cached group memberships
|
||||
val batch = BatchOperation(addressBook.provider!!)
|
||||
|
||||
// delete cached group memberships
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newDelete(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
|
||||
.withSelection(
|
||||
CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?",
|
||||
arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE, id.toString())
|
||||
))
|
||||
|
||||
// insert updated cached group memberships
|
||||
for (member in getMembers())
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newInsert(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
|
||||
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
.withValue(CachedGroupMembership.RAW_CONTACT_ID, member)
|
||||
.withValue(CachedGroupMembership.GROUP_ID, id))
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks all members of the current group as dirty.
|
||||
*/
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun markMembersDirty() {
|
||||
val batch = BatchOperation(addressBook.provider!!)
|
||||
|
||||
for (member in getMembers())
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, member)))
|
||||
.withValue(RawContacts.DIRTY, 1))
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, member)))
|
||||
.withValue(RawContacts.DIRTY, 1)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
val values = ContentValues(1)
|
||||
values.put(Groups.DELETED, 0)
|
||||
addressBook.provider!!.update(groupSyncUri(), values, null, null)
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
val values = ContentValues(1)
|
||||
values.put(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
|
||||
}
|
||||
@@ -252,8 +243,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,90 +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?
|
||||
|
||||
/**
|
||||
* Remote file name for the resource, for instance `mycontact.vcf`. Also used to determine whether
|
||||
* a dirty record has just been created (in this case, [fileName] is *null*) or modified
|
||||
* (in this case, [fileName] is the remote file name).
|
||||
*/
|
||||
val fileName: String?
|
||||
|
||||
/** remote ETag for the resource */
|
||||
var fileName: String?
|
||||
var eTag: String?
|
||||
|
||||
/** remote Schedule-Tag for the resource */
|
||||
var scheduleTag: String?
|
||||
|
||||
/** bitfield of flags; currently either [FLAG_REMOTELY_PRESENT] or 0 */
|
||||
val flags: Int
|
||||
|
||||
/**
|
||||
* Prepares the resource for uploading:
|
||||
*
|
||||
* 1. If the resource doesn't have an UID yet, this method generates one and writes it to the content provider.
|
||||
* 2. The new file name which can be used for the upload is derived from the UID and returned, but not
|
||||
* saved to the content provider. The sync manager is responsible for saving the file name that
|
||||
* was actually used.
|
||||
*
|
||||
* @return new file name of the resource (like "<uid>.vcf")
|
||||
*/
|
||||
fun prepareForUpload(): String
|
||||
|
||||
/**
|
||||
* Unsets the /dirty/ field of the resource. Typically used after successfully uploading a
|
||||
* locally modified resource.
|
||||
*
|
||||
* @param fileName If this argument is not *null*, [LocalResource.fileName] will be set to its value.
|
||||
* @param eTag ETag of the uploaded resource as returned by the server (null if the server didn't return one)
|
||||
* @param scheduleTag CalDAV Schedule-Tag of the uploaded resource as returned by the server (null if not applicable or if the server didn't return one)
|
||||
*/
|
||||
fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String? = null)
|
||||
|
||||
/**
|
||||
* Sets (local) flags of the resource. At the moment, the only allowed values are
|
||||
* 0 and [FLAG_REMOTELY_PRESENT].
|
||||
*/
|
||||
fun updateFlags(flags: Int)
|
||||
|
||||
|
||||
/**
|
||||
* Adds the data object to the content provider and ensures that the dirty flag is clear.
|
||||
*
|
||||
* @return content URI of the created row (e.g. event URI)
|
||||
*/
|
||||
fun add(): Uri
|
||||
|
||||
/**
|
||||
* Updates the data object in the content provider and ensures that the dirty flag is clear.
|
||||
*
|
||||
* @return content URI of the updated row (e.g. event URI)
|
||||
*/
|
||||
fun update(data: TData): Uri
|
||||
|
||||
/**
|
||||
* Deletes the data object from the content provider.
|
||||
*
|
||||
* @return number of affected rows
|
||||
*/
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun delete(): Int
|
||||
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun prepareForUpload()
|
||||
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun clearDirty(eTag: String?)
|
||||
|
||||
}
|
||||
@@ -8,106 +8,110 @@
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentValues
|
||||
import android.provider.CalendarContract.Events
|
||||
import at.bitfire.ical4android.*
|
||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||
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 scheduleTag: 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 */
|
||||
|
||||
override fun buildTask(builder: BatchOperation.CpoBuilder, update: Boolean) {
|
||||
@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 prepareForUpload(): String {
|
||||
var uid: String? = null
|
||||
taskList.provider.client.query(taskSyncURI(), arrayOf(Tasks._UID), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
uid = cursor.getString(0)
|
||||
}
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun prepareForUpload() {
|
||||
try {
|
||||
val uid = UUID.randomUUID().toString()
|
||||
val newFileName = uid + ".ics"
|
||||
|
||||
if (uid == null) {
|
||||
// generate new UID
|
||||
uid = UUID.randomUUID().toString()
|
||||
|
||||
val values = ContentValues(1)
|
||||
values.put(Tasks._UID, uid)
|
||||
val values = ContentValues(2)
|
||||
values.put(Tasks._SYNC_ID, newFileName)
|
||||
values.put(COLUMN_UID, uid)
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null)
|
||||
|
||||
fileName = newFileName
|
||||
|
||||
task!!.uid = uid
|
||||
} catch (e: Exception) {
|
||||
throw CalendarStorageException("Couldn't update UID", e)
|
||||
}
|
||||
|
||||
return "$uid.ics"
|
||||
}
|
||||
|
||||
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
|
||||
if (scheduleTag != null)
|
||||
Ical4Android.log.fine("Schedule-Tag for tasks not supported yet, won't save")
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun clearDirty(eTag: String?) {
|
||||
try {
|
||||
val values = ContentValues(2)
|
||||
values.put(Tasks._DIRTY, 0)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
|
||||
val values = ContentValues(4)
|
||||
if (fileName != null)
|
||||
values.put(Tasks._SYNC_ID, fileName)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(Tasks.SYNC_VERSION, task!!.sequence)
|
||||
values.put(Tasks._DIRTY, 0)
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null)
|
||||
|
||||
if (fileName != null)
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
if (id != null) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
values.put(COLUMN_SEQUENCE, task!!.sequence)
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
this.flags = flags
|
||||
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,21 +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 at.bitfire.davdroid.Constants
|
||||
import android.os.Build
|
||||
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.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,
|
||||
@@ -33,7 +33,30 @@ class LocalTaskList private constructor(
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(account: Account, provider: TaskProvider, info: Collection): Uri {
|
||||
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 {
|
||||
val provider = TaskProvider.acquire(context.contentResolver, TaskProvider.ProviderName.OpenTasks)
|
||||
provider?.use { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
@@ -41,106 +64,93 @@ class LocalTaskList private constructor(
|
||||
return create(account, provider, values)
|
||||
}
|
||||
|
||||
@SuppressLint("Recycle")
|
||||
@JvmStatic
|
||||
@Throws(Exception::class)
|
||||
fun onRenameAccount(context: Context, oldName: String, newName: String) {
|
||||
TaskProvider.acquire(context)?.use { provider ->
|
||||
val values = ContentValues(1)
|
||||
values.put(Tasks.ACCOUNT_NAME, newName)
|
||||
provider.client.update(
|
||||
Tasks.getContentUri(provider.name.authority),
|
||||
values,
|
||||
"${Tasks.ACCOUNT_NAME}=?", arrayOf(oldName)
|
||||
)
|
||||
fun onRenameAccount(resolver: ContentResolver, oldName: String, newName: String) {
|
||||
var client: ContentProviderClient? = null
|
||||
try {
|
||||
client = resolver.acquireContentProviderClient(TaskProvider.ProviderName.OpenTasks.authority)
|
||||
client?.use {
|
||||
val values = ContentValues(1)
|
||||
values.put(Tasks.ACCOUNT_NAME, newName)
|
||||
it.update(Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority), values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldName))
|
||||
}
|
||||
} finally {
|
||||
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 tag: String
|
||||
get() = "tasks-${account.name}-$id"
|
||||
|
||||
override val title: String
|
||||
get() = name ?: id.toString()
|
||||
|
||||
override var lastSyncState: SyncState?
|
||||
get() {
|
||||
try {
|
||||
provider.client.query(taskListSyncUri(), arrayOf(TaskLists.SYNC_VERSION),
|
||||
null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
cursor.getString(0)?.let {
|
||||
return SyncState.fromString(it)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't read sync state", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
set(state) {
|
||||
val values = ContentValues(1)
|
||||
values.put(TaskLists.SYNC_VERSION, state?.toString())
|
||||
provider.client.update(taskListSyncUri(), values, null, null)
|
||||
}
|
||||
override fun 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, null)
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun getAll() = queryTasks(null, null)
|
||||
|
||||
override fun findDirty(): List<LocalTask> {
|
||||
val tasks = queryTasks(Tasks._DIRTY, null)
|
||||
@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) {
|
||||
try {
|
||||
val task = requireNotNull(localTask.task)
|
||||
val sequence = task.sequence
|
||||
if (sequence == null) // sequence has not been assigned yet (i.e. this task was just locally created)
|
||||
task.sequence = 0
|
||||
else // task was modified, increase sequence
|
||||
task.sequence = sequence + 1
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't check/increase sequence", e)
|
||||
}
|
||||
val task = requireNotNull(localTask.task)
|
||||
val sequence = task.sequence
|
||||
if (sequence == null) // sequence has not been assigned yet (i.e. this task was just locally created)
|
||||
task.sequence = 0
|
||||
else
|
||||
task.sequence = sequence + 1
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
override fun findByName(name: String) =
|
||||
queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name)).firstOrNull()
|
||||
|
||||
@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()))
|
||||
}
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int) =
|
||||
provider.client.delete(tasksSyncUri(),
|
||||
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${LocalTask.COLUMN_FLAGS}=?",
|
||||
arrayOf(id.toString(), flags.toString()))
|
||||
|
||||
override fun forgetETags() {
|
||||
val values = ContentValues(1)
|
||||
values.putNull(LocalEvent.COLUMN_ETAG)
|
||||
provider.client.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
|
||||
arrayOf(id.toString()))
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -151,4 +161,4 @@ class LocalTaskList private constructor(
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.Context
|
||||
import at.bitfire.davdroid.TasksWatcher
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.ical4android.TaskProvider.ProviderName
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object TaskUtils {
|
||||
|
||||
fun currentProvider(context: Context): ProviderName? {
|
||||
val preferredAuthority = SettingsManager.getInstance(context).getString(Settings.PREFERRED_TASKS_PROVIDER)
|
||||
ProviderName.values()
|
||||
.sortedByDescending { it.authority == preferredAuthority }
|
||||
.forEach { providerName ->
|
||||
if (context.packageManager.resolveContentProvider(providerName.authority, 0) != null)
|
||||
return providerName
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun isAvailable(context: Context) = currentProvider(context) != null
|
||||
|
||||
fun setPreferredProvider(context: Context, providerName: ProviderName) {
|
||||
SettingsManager.getInstance(context).putString(Settings.PREFERRED_TASKS_PROVIDER, providerName.authority)
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
TasksWatcher.updateTaskSync(context)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,678 +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.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.os.Parcel
|
||||
import android.os.RemoteException
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.ExtendedProperties
|
||||
import android.provider.ContactsContract
|
||||
import android.util.Base64
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.content.ContextCompat
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.TasksWatcher
|
||||
import at.bitfire.davdroid.closeCompat
|
||||
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.davdroid.resource.LocalTask
|
||||
import at.bitfire.davdroid.resource.TaskUtils
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.AndroidEvent
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.ical4android.TaskProvider.ProviderName.OpenTasks
|
||||
import at.bitfire.ical4android.UnknownProperty
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import net.fortuna.ical4j.model.Property
|
||||
import net.fortuna.ical4j.model.property.Url
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.dmfs.tasks.contract.TaskContract
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ObjectInputStream
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Manages settings of an account.
|
||||
*
|
||||
* @throws InvalidAccountException on construction when the account doesn't exist (anymore)
|
||||
*/
|
||||
@Suppress("FunctionName")
|
||||
class AccountSettings(
|
||||
val context: Context,
|
||||
val account: Account
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
const val CURRENT_VERSION = 12
|
||||
const val KEY_SETTINGS_VERSION = "version"
|
||||
|
||||
const val KEY_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks"
|
||||
const val KEY_SYNC_INTERVAL_CALENDARS = "sync_interval_calendars"
|
||||
|
||||
/** Stores the tasks sync interval (in seconds) so that it can be set again when the provider is switched */
|
||||
const val KEY_SYNC_INTERVAL_TASKS = "sync_interval_tasks"
|
||||
|
||||
const val KEY_USERNAME = "user_name"
|
||||
const val KEY_CERTIFICATE_ALIAS = "certificate_alias"
|
||||
|
||||
const val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false)
|
||||
const val KEY_WIFI_ONLY_SSIDS = "wifi_only_ssids" // restrict sync to specific WiFi SSIDs
|
||||
|
||||
/** Time range limitation to the past [in days]. Values:
|
||||
*
|
||||
* - null: default value (DEFAULT_TIME_RANGE_PAST_DAYS)
|
||||
* - <0 (typically -1): no limit
|
||||
* - n>0: entries more than n days in the past won't be synchronized
|
||||
*/
|
||||
const val KEY_TIME_RANGE_PAST_DAYS = "time_range_past_days"
|
||||
const val DEFAULT_TIME_RANGE_PAST_DAYS = 90
|
||||
|
||||
/**
|
||||
* Whether a default alarm shall be assigned to received events/tasks which don't have an alarm.
|
||||
* Value can be null (no default alarm) or an integer (default alarm shall be created this
|
||||
* number of minutes before the event/task).
|
||||
*/
|
||||
const val KEY_DEFAULT_ALARM = "default_alarm"
|
||||
|
||||
/** Whether DAVx5 sets the local calendar color to the value from service DB at every sync
|
||||
value = *null* (not existing): true (default);
|
||||
"0" false */
|
||||
const val KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors"
|
||||
|
||||
/** Whether DAVx5 populates and uses CalendarContract.Colors
|
||||
value = *null* (not existing) false (default);
|
||||
"1" true */
|
||||
const val KEY_EVENT_COLORS = "event_colors"
|
||||
|
||||
/** Contact group method:
|
||||
*null (not existing)* groups as separate vCards (default);
|
||||
"CATEGORIES" groups are per-contact CATEGORIES
|
||||
*/
|
||||
const val KEY_CONTACT_GROUP_METHOD = "contact_group_method"
|
||||
|
||||
/** UI preference: Show only personal collections
|
||||
value = *null* (not existing) show all collections (default);
|
||||
"1" show only personal collections */
|
||||
const val KEY_SHOW_ONLY_PERSONAL = "show_only_personal"
|
||||
|
||||
const val SYNC_INTERVAL_MANUALLY = -1L
|
||||
|
||||
|
||||
fun initialUserData(credentials: Credentials?): Bundle {
|
||||
val bundle = Bundle(2)
|
||||
bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
|
||||
|
||||
if (credentials != null) {
|
||||
if (credentials.userName != null)
|
||||
bundle.putString(KEY_USERNAME, credentials.userName)
|
||||
if (credentials.certificateAlias != null)
|
||||
bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
|
||||
}
|
||||
|
||||
return bundle
|
||||
}
|
||||
|
||||
fun repairSyncIntervals(context: Context) {
|
||||
val addressBooksAuthority = context.getString(R.string.address_books_authority)
|
||||
val taskAuthority = TaskUtils.currentProvider(context)?.authority
|
||||
|
||||
val am = AccountManager.get(context)
|
||||
for (account in am.getAccountsByType(context.getString(R.string.account_type)))
|
||||
try {
|
||||
val settings = AccountSettings(context, account)
|
||||
|
||||
// repair address book sync
|
||||
settings.getSavedAddressbooksSyncInterval()?.let { shouldBe ->
|
||||
val current = settings.getSyncInterval(addressBooksAuthority)
|
||||
if (current != shouldBe) {
|
||||
Logger.log.warning("${account.name}: $addressBooksAuthority sync interval should be $shouldBe but is $current -> setting to $current")
|
||||
settings.setSyncInterval(addressBooksAuthority, shouldBe)
|
||||
}
|
||||
}
|
||||
|
||||
// repair calendar sync
|
||||
settings.getSavedCalendarsSyncInterval()?.let { shouldBe ->
|
||||
val current = settings.getSyncInterval(CalendarContract.AUTHORITY)
|
||||
if (current != shouldBe) {
|
||||
Logger.log.warning("${account.name}: ${CalendarContract.AUTHORITY} sync interval should be $shouldBe but is $current -> setting to $current")
|
||||
settings.setSyncInterval(CalendarContract.AUTHORITY, shouldBe)
|
||||
}
|
||||
}
|
||||
|
||||
if (taskAuthority != null)
|
||||
// repair calendar sync
|
||||
settings.getSavedTasksSyncInterval()?.let { shouldBe ->
|
||||
val current = settings.getSyncInterval(taskAuthority)
|
||||
if (current != shouldBe) {
|
||||
Logger.log.warning("${account.name}: $taskAuthority sync interval should be $shouldBe but is $current -> setting to $current")
|
||||
settings.setSyncInterval(taskAuthority, shouldBe)
|
||||
}
|
||||
}
|
||||
} catch (ignored: InvalidAccountException) {
|
||||
// account doesn't exist (anymore)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
val accountManager: AccountManager = AccountManager.get(context)
|
||||
val settings = SettingsManager.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
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the sync interval and enables/disables automatic sync for the given account and authority.
|
||||
* Does *not* call [ContentResolver.setIsSyncable].
|
||||
*
|
||||
* This method blocks until the settings have arrived in the sync framework, so it should not
|
||||
* be called from the UI thread.
|
||||
*
|
||||
* @param authority sync authority (like [CalendarContract.AUTHORITY])
|
||||
* @param seconds if [SYNC_INTERVAL_MANUALLY]: automatic sync will be disabled;
|
||||
* otherwise: automatic sync will be enabled and set to the given number of seconds
|
||||
*
|
||||
* @return whether the sync interval was successfully set
|
||||
*/
|
||||
@WorkerThread
|
||||
fun setSyncInterval(authority: String, seconds: Long): Boolean {
|
||||
/* Ugly hack: because there is no callback for when the sync status/interval has been
|
||||
updated, we need to make this call blocking. */
|
||||
val setInterval: () -> Boolean =
|
||||
if (seconds == SYNC_INTERVAL_MANUALLY) {
|
||||
{
|
||||
Logger.log.fine("Disabling automatic sync of $account/$authority")
|
||||
ContentResolver.setSyncAutomatically(account, authority, false)
|
||||
|
||||
/* return */ !ContentResolver.getSyncAutomatically(account, authority)
|
||||
}
|
||||
} else {
|
||||
{
|
||||
Logger.log.fine("Setting automatic sync of $account/$authority to $seconds seconds")
|
||||
ContentResolver.setSyncAutomatically(account, authority, true)
|
||||
ContentResolver.addPeriodicSync(account, authority, Bundle(), seconds)
|
||||
|
||||
/* return */ ContentResolver.getSyncAutomatically(account, authority) &&
|
||||
ContentResolver.getPeriodicSyncs(account, authority).firstOrNull()?.period == seconds
|
||||
}
|
||||
}
|
||||
|
||||
// try up to 10 times with 100 ms pause
|
||||
var success = false
|
||||
for (idxTry in 0 until 10) {
|
||||
success = setInterval()
|
||||
if (success)
|
||||
break
|
||||
Thread.sleep(100)
|
||||
}
|
||||
|
||||
if (!success)
|
||||
return false
|
||||
|
||||
// store sync interval in account settings (used when the provider is switched)
|
||||
if (authority == context.getString(R.string.address_books_authority))
|
||||
accountManager.setUserData(account, KEY_SYNC_INTERVAL_ADDRESSBOOKS, seconds.toString())
|
||||
else if (authority == CalendarContract.AUTHORITY)
|
||||
accountManager.setUserData(account, KEY_SYNC_INTERVAL_CALENDARS, seconds.toString())
|
||||
else if (TaskProvider.ProviderName.values().any { it.authority == authority })
|
||||
accountManager.setUserData(account, KEY_SYNC_INTERVAL_TASKS, seconds.toString())
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun getSavedAddressbooksSyncInterval() = accountManager.getUserData(account, KEY_SYNC_INTERVAL_ADDRESSBOOKS)?.toLong()
|
||||
fun getSavedCalendarsSyncInterval() = accountManager.getUserData(account, KEY_SYNC_INTERVAL_CALENDARS)?.toLong()
|
||||
fun getSavedTasksSyncInterval() = accountManager.getUserData(account, KEY_SYNC_INTERVAL_TASKS)?.toLong()
|
||||
|
||||
fun getSyncWifiOnly() =
|
||||
if (settings.containsKey(KEY_WIFI_ONLY))
|
||||
settings.getBoolean(KEY_WIFI_ONLY)
|
||||
else
|
||||
accountManager.getUserData(account, KEY_WIFI_ONLY) != null
|
||||
fun setSyncWiFiOnly(wiFiOnly: Boolean) =
|
||||
accountManager.setUserData(account, KEY_WIFI_ONLY, if (wiFiOnly) "1" else null)
|
||||
|
||||
fun getSyncWifiOnlySSIDs(): List<String>? =
|
||||
if (getSyncWifiOnly()) {
|
||||
val strSsids = if (settings.containsKey(KEY_WIFI_ONLY_SSIDS))
|
||||
settings.getString(KEY_WIFI_ONLY_SSIDS)
|
||||
else
|
||||
accountManager.getUserData(account, KEY_WIFI_ONLY_SSIDS)
|
||||
strSsids?.split(',')
|
||||
} else
|
||||
null
|
||||
fun setSyncWifiOnlySSIDs(ssids: List<String>?) =
|
||||
accountManager.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 = strDays.toInt()
|
||||
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())
|
||||
|
||||
/**
|
||||
* Takes the default alarm setting (in this order) from
|
||||
*
|
||||
* 1. the local account settings
|
||||
* 2. the settings provider (unless the value is -1 there).
|
||||
*
|
||||
* @return A default reminder shall be created this number of minutes before the start of every
|
||||
* non-full-day event without reminder. *null*: No default reminders shall be created.
|
||||
*/
|
||||
fun getDefaultAlarm() =
|
||||
accountManager.getUserData(account, KEY_DEFAULT_ALARM)?.toInt() ?:
|
||||
settings.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 }
|
||||
|
||||
/**
|
||||
* Sets the default alarm value in the local account settings, if the new value differs
|
||||
* from the value of the settings provider. If the new value is the same as the value of
|
||||
* the settings provider, the local setting will be deleted, so that the settings provider
|
||||
* value applies.
|
||||
*
|
||||
* @param minBefore The number of minutes a default reminder shall be created before the
|
||||
* start of every non-full-day event without reminder. *null*: No default reminders shall be created.
|
||||
*/
|
||||
fun setDefaultAlarm(minBefore: Int?) =
|
||||
accountManager.setUserData(account, KEY_DEFAULT_ALARM,
|
||||
if (minBefore == settings.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 })
|
||||
null
|
||||
else
|
||||
minBefore?.toString())
|
||||
|
||||
fun getManageCalendarColors() = if (settings.containsKey(KEY_MANAGE_CALENDAR_COLORS))
|
||||
settings.getBoolean(KEY_MANAGE_CALENDAR_COLORS)
|
||||
else
|
||||
accountManager.getUserData(account, KEY_MANAGE_CALENDAR_COLORS) == null
|
||||
fun setManageCalendarColors(manage: Boolean) =
|
||||
accountManager.setUserData(account, KEY_MANAGE_CALENDAR_COLORS, if (manage) null else "0")
|
||||
|
||||
fun getEventColors() = if (settings.containsKey(KEY_EVENT_COLORS))
|
||||
settings.getBoolean(KEY_EVENT_COLORS)
|
||||
else
|
||||
accountManager.getUserData(account, KEY_EVENT_COLORS) != null
|
||||
fun setEventColors(useColors: Boolean) =
|
||||
accountManager.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)
|
||||
}
|
||||
|
||||
|
||||
// UI settings
|
||||
|
||||
/**
|
||||
* Whether only personal collections should be shown.
|
||||
*
|
||||
* @return [Pair] of values:
|
||||
*
|
||||
* 1. (first) whether only personal collections should be shown
|
||||
* 2. (second) whether the user shall be able to change the setting (= setting not locked)
|
||||
*/
|
||||
fun getShowOnlyPersonal(): Pair<Boolean, Boolean> =
|
||||
when (settings.getIntOrNull(KEY_SHOW_ONLY_PERSONAL)) {
|
||||
0 -> Pair(false, false)
|
||||
1 -> Pair(true, false)
|
||||
else /* including -1 */ -> Pair(accountManager.getUserData(account, KEY_SHOW_ONLY_PERSONAL) != null, true)
|
||||
}
|
||||
|
||||
fun setShowOnlyPersonal(showOnlyPersonal: Boolean) {
|
||||
accountManager.setUserData(account, KEY_SHOW_ONLY_PERSONAL, if (showOnlyPersonal) "1" else null)
|
||||
}
|
||||
|
||||
|
||||
// update from previous account settings
|
||||
|
||||
private fun update(baseVersion: Int) {
|
||||
for (toVersion in baseVersion+1 ..CURRENT_VERSION) {
|
||||
val fromVersion = toVersion-1
|
||||
Logger.log.info("Updating account ${account.name} from version $fromVersion to $toVersion")
|
||||
try {
|
||||
val 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")
|
||||
/**
|
||||
* Store event URLs as URL (extended property) instead of unknown property. At the same time,
|
||||
* convert legacy unknown properties to the current format.
|
||||
*/
|
||||
private fun update_11_12() {
|
||||
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED)
|
||||
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { provider ->
|
||||
// Attention: CalendarProvider does NOT limit the results of the ExtendedProperties query
|
||||
// to the given account! So all extended properties will be processed number-of-accounts times.
|
||||
val extUri = AndroidCalendar.syncAdapterURI(ExtendedProperties.CONTENT_URI, account)
|
||||
|
||||
provider.query(extUri, arrayOf(
|
||||
ExtendedProperties._ID, // idx 0
|
||||
ExtendedProperties.NAME, // idx 1
|
||||
ExtendedProperties.VALUE // idx 2
|
||||
), null, null, null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(0)
|
||||
val rawValue = cursor.getString(2)
|
||||
|
||||
val uri by lazy {
|
||||
AndroidCalendar.syncAdapterURI(ContentUris.withAppendedId(ExtendedProperties.CONTENT_URI, id), account)
|
||||
}
|
||||
|
||||
when (cursor.getString(1)) {
|
||||
UnknownProperty.CONTENT_ITEM_TYPE -> {
|
||||
// unknown property; check whether it's a URL
|
||||
try {
|
||||
val property = UnknownProperty.fromJsonString(rawValue)
|
||||
if (property is Url) { // rewrite to MIMETYPE_URL
|
||||
val newValues = ContentValues(2)
|
||||
newValues.put(ExtendedProperties.NAME, AndroidEvent.MIMETYPE_URL)
|
||||
newValues.put(ExtendedProperties.VALUE, property.value)
|
||||
provider.update(uri, newValues, null, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't rewrite URL from unknown property to ${AndroidEvent.MIMETYPE_URL}", e)
|
||||
}
|
||||
}
|
||||
"unknown-property" -> {
|
||||
// unknown property (deprecated format); convert to current format
|
||||
try {
|
||||
val stream = ByteArrayInputStream(Base64.decode(rawValue, Base64.NO_WRAP))
|
||||
ObjectInputStream(stream).use {
|
||||
(it.readObject() as? Property)?.let { property ->
|
||||
// rewrite to current format
|
||||
val newValues = ContentValues(2)
|
||||
newValues.put(ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE)
|
||||
newValues.put(ExtendedProperties.VALUE, UnknownProperty.toJsonString(property))
|
||||
provider.update(uri, newValues, null, null)
|
||||
}
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't rewrite deprecated unknown property to current format", e)
|
||||
}
|
||||
}
|
||||
"unknown-property.v2" -> {
|
||||
// unknown property (deprecated MIME type); rewrite to current MIME type
|
||||
val newValues = ContentValues(1)
|
||||
newValues.put(ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE)
|
||||
provider.update(uri, newValues, null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused","FunctionName")
|
||||
/**
|
||||
* The tasks sync interval should be stored in account settings. It's used to set the sync interval
|
||||
* again when the tasks provider is switched.
|
||||
*/
|
||||
private fun update_10_11() {
|
||||
TaskUtils.currentProvider(context)?.let { provider ->
|
||||
val interval = getSyncInterval(provider.authority)
|
||||
if (interval != null)
|
||||
accountManager.setUserData(account, KEY_SYNC_INTERVAL_TASKS, interval.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused","FunctionName")
|
||||
/**
|
||||
* Task synchronization now handles alarms, categories, relations and unknown properties.
|
||||
* Setting task ETags to null will cause them to be downloaded (and parsed) again.
|
||||
*
|
||||
* Also update the allowed reminder types for calendars.
|
||||
**/
|
||||
private fun update_9_10() {
|
||||
TaskProvider.acquire(context, OpenTasks)?.use { provider ->
|
||||
val tasksUri = TaskProvider.syncAdapterUri(provider.tasksUri(), account)
|
||||
val emptyETag = ContentValues(1)
|
||||
emptyETag.putNull(LocalTask.COLUMN_ETAG)
|
||||
provider.client.update(tasksUri, emptyETag, "${TaskContract.Tasks._DIRTY}=0 AND ${TaskContract.Tasks._DELETED}=0", null)
|
||||
}
|
||||
|
||||
@SuppressLint("Recycle")
|
||||
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED)
|
||||
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider ->
|
||||
provider.update(AndroidCalendar.syncAdapterURI(CalendarContract.Calendars.CONTENT_URI, account),
|
||||
AndroidCalendar.calendarBaseValues, null, null)
|
||||
provider.closeCompat()
|
||||
}
|
||||
}
|
||||
|
||||
@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")?.toHttpUrlOrNull()
|
||||
if (url == null)
|
||||
Logger.log.info("No address book URL, ignoring account")
|
||||
else {
|
||||
// create new address book
|
||||
val info = Collection(url = url, type = Collection.TYPE_ADDRESSBOOK, displayName = account.name)
|
||||
Logger.log.log(Level.INFO, "Creating new address book account", url)
|
||||
val addressBookAccount = Account(LocalAddressBook.accountName(account, info), context.getString(R.string.account_type_address_book))
|
||||
if (!accountManager.addAccountExplicitly(addressBookAccount, null, LocalAddressBook.initialUserData(account, info.url.toString())))
|
||||
throw ContactsStorageException("Couldn't create address book account")
|
||||
|
||||
// move contacts to new address book
|
||||
Logger.log.info("Moving contacts from $account to $addressBookAccount")
|
||||
val newAccount = ContentValues(2)
|
||||
newAccount.put(ContactsContract.RawContacts.ACCOUNT_NAME, addressBookAccount.name)
|
||||
newAccount.put(ContactsContract.RawContacts.ACCOUNT_TYPE, addressBookAccount.type)
|
||||
val affected = provider.update(ContactsContract.RawContacts.CONTENT_URI.buildUpon()
|
||||
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
|
||||
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
|
||||
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build(),
|
||||
newAccount,
|
||||
"${ContactsContract.RawContacts.ACCOUNT_NAME}=? AND ${ContactsContract.RawContacts.ACCOUNT_TYPE}=?",
|
||||
arrayOf(account.name, account.type))
|
||||
Logger.log.info("$affected contacts moved to new address book")
|
||||
}
|
||||
|
||||
ContactsContract.SyncState.set(provider, account, null)
|
||||
}
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't migrate contacts to new address book", e)
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
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), 4*3600)
|
||||
}
|
||||
|
||||
/* Android 7.1.1 OpenTasks fix */
|
||||
@Suppress("unused")
|
||||
private fun update_4_5() {
|
||||
// call PackageChangedReceiver which then enables/disables OpenTasks sync when it's (not) available
|
||||
TasksWatcher.updateTaskSync(context)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun update_3_4() {
|
||||
setGroupMethod(GroupMethod.CATEGORIES)
|
||||
}
|
||||
|
||||
// updates from AccountSettings version 2 and below are not supported anymore
|
||||
|
||||
}
|
||||
@@ -1,69 +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 at.bitfire.davdroid.TextTable
|
||||
import java.io.Writer
|
||||
|
||||
abstract class BaseDefaultsProvider(
|
||||
val context: Context,
|
||||
val settingsManager: SettingsManager
|
||||
): SettingsProvider {
|
||||
|
||||
abstract val booleanDefaults: Map<String, Boolean>
|
||||
abstract val intDefaults: Map<String, Int>
|
||||
abstract val longDefaults: Map<String, Long>
|
||||
abstract val stringDefaults: Map<String, String?>
|
||||
|
||||
|
||||
override fun canWrite() = false
|
||||
|
||||
override fun close() {
|
||||
// override this, if needed
|
||||
}
|
||||
|
||||
override fun forceReload() {
|
||||
// override this, if needed
|
||||
}
|
||||
|
||||
|
||||
override fun contains(key: String) =
|
||||
booleanDefaults.containsKey(key) ||
|
||||
intDefaults.containsKey(key) ||
|
||||
longDefaults.containsKey(key) ||
|
||||
stringDefaults.containsKey(key)
|
||||
|
||||
override fun getBoolean(key: String) = booleanDefaults[key]
|
||||
override fun getInt(key: String) = intDefaults[key]
|
||||
override fun getLong(key: String) = longDefaults[key]
|
||||
override fun getString(key: String) = stringDefaults[key]
|
||||
|
||||
override fun putBoolean(key: String, value: Boolean?) = throw NotImplementedError()
|
||||
override fun putInt(key: String, value: Int?) = throw NotImplementedError()
|
||||
override fun putLong(key: String, value: Long?) = throw NotImplementedError()
|
||||
override fun putString(key: String, value: String?) = throw NotImplementedError()
|
||||
|
||||
override fun remove(key: String) = throw NotImplementedError()
|
||||
|
||||
|
||||
override fun dump(writer: Writer) {
|
||||
val strValues = mutableMapOf<String, String?>()
|
||||
strValues.putAll(booleanDefaults.mapValues { (_, value) -> value.toString() })
|
||||
strValues.putAll(intDefaults.mapValues { (_, value) -> value.toString() })
|
||||
strValues.putAll(longDefaults.mapValues { (_, value) -> value.toString() })
|
||||
strValues.putAll(stringDefaults)
|
||||
|
||||
val table = TextTable("Setting", "Value")
|
||||
for ((key, value) in strValues.toSortedMap())
|
||||
table.addLine(key, value)
|
||||
writer.write(table.toString())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import androidx.core.content.getSystemService
|
||||
|
||||
class DefaultsProvider(
|
||||
context: Context,
|
||||
settingsManager: SettingsManager
|
||||
): BaseDefaultsProvider(context, settingsManager) {
|
||||
|
||||
override val booleanDefaults = mutableMapOf(
|
||||
Pair(Settings.DISTRUST_SYSTEM_CERTIFICATES, false),
|
||||
Pair(Settings.OVERRIDE_PROXY, false)
|
||||
)
|
||||
|
||||
override val intDefaults = mapOf(
|
||||
Pair(Settings.OVERRIDE_PROXY_PORT, 8118)
|
||||
)
|
||||
|
||||
override val longDefaults = mapOf<String, Long>(
|
||||
Pair(Settings.DEFAULT_SYNC_INTERVAL, 4*3600) /* 4 hours */
|
||||
)
|
||||
|
||||
override val stringDefaults = mapOf(
|
||||
Pair(Settings.OVERRIDE_PROXY_HOST, "localhost")
|
||||
)
|
||||
|
||||
val dataSaverChangedListener by lazy {
|
||||
object: BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
evaluateDataSaver(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
init {
|
||||
if (Build.VERSION.SDK_INT >= 24) {
|
||||
val dataSaverChangedFilter = IntentFilter(ConnectivityManager.ACTION_RESTRICT_BACKGROUND_CHANGED)
|
||||
context.registerReceiver(dataSaverChangedListener, dataSaverChangedFilter)
|
||||
evaluateDataSaver()
|
||||
}
|
||||
}
|
||||
|
||||
override fun forceReload() {
|
||||
evaluateDataSaver()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (Build.VERSION.SDK_INT >= 24)
|
||||
context.unregisterReceiver(dataSaverChangedListener)
|
||||
}
|
||||
|
||||
fun evaluateDataSaver(notify: Boolean = false) {
|
||||
if (Build.VERSION.SDK_INT >= 24) {
|
||||
context.getSystemService<ConnectivityManager>()?.let { connectivityManager ->
|
||||
if (connectivityManager.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED)
|
||||
booleanDefaults[AccountSettings.KEY_WIFI_ONLY] = true
|
||||
else
|
||||
booleanDefaults -= AccountSettings.KEY_WIFI_ONLY
|
||||
}
|
||||
if (notify)
|
||||
settingsManager.onSettingsChanged()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Factory : SettingsProviderFactory {
|
||||
override fun getProviders(context: Context, settingsManager: SettingsManager) =
|
||||
listOf(DefaultsProvider(context, settingsManager))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
|
||||
object Settings {
|
||||
|
||||
const val FOREGROUND_SERVICE = "foreground_service"
|
||||
|
||||
const val DISTRUST_SYSTEM_CERTIFICATES = "distrust_system_certs"
|
||||
|
||||
const val OVERRIDE_PROXY = "override_proxy"
|
||||
const val OVERRIDE_PROXY_HOST = "override_proxy_host"
|
||||
const val OVERRIDE_PROXY_PORT = "override_proxy_port"
|
||||
|
||||
/**
|
||||
* Default sync interval (long), in seconds.
|
||||
* Used to initialize an account.
|
||||
*/
|
||||
const val DEFAULT_SYNC_INTERVAL = "default_sync_interval"
|
||||
|
||||
/**
|
||||
* Preferred theme (light/dark). Value must be one of [AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM]
|
||||
* (default if setting is missing), [AppCompatDelegate.MODE_NIGHT_NO] or [AppCompatDelegate.MODE_NIGHT_YES].
|
||||
*/
|
||||
const val PREFERRED_THEME = "preferred_theme"
|
||||
const val PREFERRED_THEME_DEFAULT = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
|
||||
const val PREFERRED_TASKS_PROVIDER = "preferred_tasks_provider"
|
||||
|
||||
}
|
||||
@@ -1,175 +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.util.NoSuchPropertyException
|
||||
import androidx.annotation.AnyThread
|
||||
import at.bitfire.davdroid.AndroidSingleton
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import java.io.Writer
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Settings manager which coordinates [SettingsProvider]s to read/write
|
||||
* application settings.
|
||||
*/
|
||||
class SettingsManager private constructor(
|
||||
context: Context
|
||||
) {
|
||||
|
||||
companion object: AndroidSingleton<SettingsManager>() {
|
||||
override fun createInstance(context: Context) = SettingsManager(context)
|
||||
}
|
||||
|
||||
private val providers = LinkedList<SettingsProvider>()
|
||||
private var writeProvider: SettingsProvider? = null
|
||||
|
||||
private val observers = LinkedList<WeakReference<OnChangeListener>>()
|
||||
|
||||
init {
|
||||
val factories = ServiceLoader.load(SettingsProviderFactory::class.java, context.classLoader)
|
||||
Logger.log.fine("Loading settings providers from ${factories.count()} factories")
|
||||
for (factory in factories)
|
||||
providers.addAll(factory.getProviders(context, this))
|
||||
|
||||
writeProvider = providers.first { it.canWrite() }
|
||||
Logger.log.fine("Changed settings are handled by $writeProvider")
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests all providers to reload their settings.
|
||||
*/
|
||||
@AnyThread
|
||||
fun forceReload() {
|
||||
for (provider in providers)
|
||||
provider.forceReload()
|
||||
onSettingsChanged()
|
||||
}
|
||||
|
||||
|
||||
/*** OBSERVERS ***/
|
||||
|
||||
fun addOnChangeListener(observer: OnChangeListener) {
|
||||
synchronized(observers) {
|
||||
observers += WeakReference(observer)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeOnChangeListener(observer: OnChangeListener) {
|
||||
synchronized(observers) {
|
||||
observers.removeAll { it.get() == null || it.get() == observer }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies registered listeners about changes in the configuration.
|
||||
* Should be called by config providers when settings have changed.
|
||||
*/
|
||||
@AnyThread
|
||||
fun onSettingsChanged() {
|
||||
synchronized(observers) {
|
||||
for (observer in observers.mapNotNull { it.get() })
|
||||
observer.onSettingsChanged()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*** SETTINGS ACCESS ***/
|
||||
|
||||
fun containsKey(key: String) = providers.any { it.contains(key) }
|
||||
|
||||
private fun<T> getValue(key: String, reader: (SettingsProvider) -> T?): T? {
|
||||
Logger.log.fine("Looking up setting $key")
|
||||
val result: T? = null
|
||||
for (provider in providers)
|
||||
try {
|
||||
val value = reader(provider)
|
||||
Logger.log.finer("${provider::class.java.simpleName}: $key = $value")
|
||||
if (value != null) {
|
||||
Logger.log.fine("Looked up setting $key -> $value")
|
||||
return value
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't read setting from $provider", e)
|
||||
}
|
||||
Logger.log.fine("Looked up setting $key -> no result")
|
||||
return result
|
||||
}
|
||||
|
||||
fun getBooleanOrNull(key: String): Boolean? = getValue(key) { provider -> provider.getBoolean(key) }
|
||||
fun getBoolean(key: String): Boolean = getBooleanOrNull(key) ?: throw NoSuchPropertyException(key)
|
||||
|
||||
fun getIntOrNull(key: String): Int? = getValue(key) { provider -> provider.getInt(key) }
|
||||
fun getInt(key: String): Int = getIntOrNull(key) ?: throw NoSuchPropertyException(key)
|
||||
|
||||
fun getLongOrNull(key: String): Long? = getValue(key) { provider -> provider.getLong(key) }
|
||||
fun getLong(key: String) = getLongOrNull(key) ?: throw NoSuchPropertyException(key)
|
||||
|
||||
fun getString(key: String) = getValue(key) { provider -> provider.getString(key) }
|
||||
|
||||
|
||||
fun isWritable(key: String): Boolean {
|
||||
for (provider in providers) {
|
||||
if (provider.canWrite())
|
||||
return true
|
||||
else if (provider.contains(key))
|
||||
// non-writeable provider contains this key -> setting will always be provided by this read-only provider
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun<T> putValue(key: String, value: T?, writer: (SettingsProvider) -> Unit) {
|
||||
Logger.log.fine("Trying to write setting $key = $value")
|
||||
val provider = writeProvider ?: return
|
||||
try {
|
||||
writer(provider)
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't write setting to $writeProvider", e)
|
||||
}
|
||||
}
|
||||
|
||||
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) = putString(key, null)
|
||||
|
||||
|
||||
/*** HELPERS ***/
|
||||
|
||||
fun dump(writer: Writer) {
|
||||
for ((idx, provider) in providers.withIndex()) {
|
||||
writer.write("${idx + 1}. ${provider::class.java.simpleName} canWrite=${provider.canWrite()}\n")
|
||||
provider.dump(writer)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
interface OnChangeListener {
|
||||
/**
|
||||
* Will be called when something has changed in a [SettingsProvider].
|
||||
* May run in worker thread!
|
||||
*/
|
||||
@AnyThread
|
||||
fun onSettingsChanged()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,54 +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.annotation.AnyThread
|
||||
import java.io.Writer
|
||||
|
||||
/**
|
||||
* Defines a settings provider, which provides settings from a certain source
|
||||
* to the [SettingsManager].
|
||||
*
|
||||
* Implementations must be thread-safe and synchronize get/put operations on their own.
|
||||
*/
|
||||
interface SettingsProvider {
|
||||
|
||||
/**
|
||||
* Whether this provider can write settings.
|
||||
*
|
||||
* If this method returns false, the put...() methods will never be called for this provider.
|
||||
*
|
||||
* @return true = this provider provides read/write settings;
|
||||
* false = this provider provides read-only settings
|
||||
*/
|
||||
fun canWrite(): Boolean
|
||||
|
||||
fun close()
|
||||
|
||||
@AnyThread
|
||||
fun forceReload()
|
||||
|
||||
fun contains(key: String): Boolean
|
||||
|
||||
fun getBoolean(key: String): Boolean?
|
||||
fun getInt(key: String): Int?
|
||||
fun getLong(key: String): Long?
|
||||
fun getString(key: String): String?
|
||||
|
||||
fun putBoolean(key: String, value: Boolean?)
|
||||
fun putInt(key: String, value: Int?)
|
||||
fun putLong(key: String, value: Long?)
|
||||
fun putString(key: String, value: String?)
|
||||
|
||||
fun remove(key: String)
|
||||
|
||||
|
||||
fun dump(writer: Writer)
|
||||
|
||||
}
|
||||
@@ -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 SettingsProviderFactory {
|
||||
|
||||
fun getProviders(context: Context, settingsManager: SettingsManager): Iterable<SettingsProvider>
|
||||
|
||||
}
|
||||
@@ -1,139 +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 androidx.preference.PreferenceManager
|
||||
import at.bitfire.davdroid.TextTable
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
import java.io.Writer
|
||||
|
||||
class SharedPreferencesProvider(
|
||||
val context: Context,
|
||||
val settingsManager: SettingsManager
|
||||
): 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 canWrite() = true
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
settingsManager.onSettingsChanged()
|
||||
}
|
||||
|
||||
|
||||
override fun contains(key: String) = preferences.contains(key)
|
||||
|
||||
private fun<T> getValue(key: String, reader: (SharedPreferences) -> T): T? =
|
||||
try {
|
||||
if (preferences.contains(key))
|
||||
reader(preferences)
|
||||
else
|
||||
null
|
||||
} catch(e: ClassCastException) {
|
||||
null
|
||||
}
|
||||
|
||||
override fun getBoolean(key: String) =
|
||||
getValue(key) { preferences -> preferences.getBoolean(key, /* will never be used: */ false) }
|
||||
|
||||
override fun getInt(key: String) =
|
||||
getValue(key) { preferences -> preferences.getInt(key, /* will never be used: */ -1) }
|
||||
|
||||
override fun getLong(key: String) =
|
||||
getValue(key) { preferences -> preferences.getLong(key, /* will never be used: */ -1) }
|
||||
|
||||
override fun getString(key: String): String? =
|
||||
preferences.getString(key, /* will never be used: */ null)
|
||||
|
||||
|
||||
private fun<T> putValue(key: String, value: T?, writer: (SharedPreferences.Editor, T) -> Unit) {
|
||||
if (value == null)
|
||||
remove(key)
|
||||
else {
|
||||
Logger.log.fine("Writing setting $key = $value")
|
||||
val edit = preferences.edit()
|
||||
writer(edit, value)
|
||||
edit.apply()
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
Logger.log.fine("Removing setting $key")
|
||||
preferences.edit().remove(key).apply()
|
||||
}
|
||||
|
||||
|
||||
override fun dump(writer: Writer) {
|
||||
val table = TextTable("Setting", "Value")
|
||||
for ((key, value) in preferences.all.toSortedMap())
|
||||
table.addLine(key, value)
|
||||
writer.write(table.toString())
|
||||
}
|
||||
|
||||
|
||||
|
||||
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 : SettingsProviderFactory {
|
||||
override fun getProviders(context: Context, settingsManager: SettingsManager) = listOf(SharedPreferencesProvider(context, settingsManager))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,49 +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 at.bitfire.davdroid.ui.setup.LoginActivity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
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 {
|
||||
|
||||
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>?) {
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
AccountUtils.cleanupAccounts(this@AccountAuthenticatorService)
|
||||
}
|
||||
}
|
||||
accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT }
|
||||
|
||||
|
||||
private class AccountAuthenticator(
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.AnyThread
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import java.util.logging.Level
|
||||
|
||||
object AccountUtils {
|
||||
|
||||
@AnyThread
|
||||
@Synchronized
|
||||
fun cleanupAccounts(context: Context) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
|
||||
val accounts = accountManager.getAccountsByType(context.getString(R.string.account_type))
|
||||
val accountNames = accounts.map { it.name }
|
||||
Logger.log.log(Level.INFO, "Cleaning up orphaned accounts. Current accounts:", accountNames)
|
||||
|
||||
// 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())
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an account and makes sure the user data are set correctly.
|
||||
*
|
||||
* @param context operating context
|
||||
* @param account account to create
|
||||
* @param userData user data to set
|
||||
*
|
||||
* @return whether the account has been created
|
||||
*
|
||||
* @throws IllegalArgumentException when user data contains non-String values
|
||||
* @throws IllegalStateException if user data can't be set
|
||||
*/
|
||||
fun createAccount(context: Context, account: Account, userData: Bundle, password: String? = null): Boolean {
|
||||
// validate user data
|
||||
for (key in userData.keySet()) {
|
||||
userData.get(key)?.let { entry ->
|
||||
if (entry !is String)
|
||||
throw IllegalArgumentException("userData[$key] is ${entry::class.java} (expected: String)")
|
||||
}
|
||||
}
|
||||
|
||||
// create account
|
||||
val manager = AccountManager.get(context)
|
||||
if (!manager.addAccountExplicitly(account, password, userData))
|
||||
return false
|
||||
|
||||
// Android seems to lose the initial user data sometimes, so set it a second time if that happens
|
||||
// https://forums.bitfire.at/post/11644
|
||||
if (!verifyUserData(context, account, userData))
|
||||
for (key in userData.keySet())
|
||||
manager.setUserData(account, key, userData.getString(key))
|
||||
|
||||
if (!verifyUserData(context, account, userData))
|
||||
throw IllegalStateException("Android doesn't store user data in account")
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun verifyUserData(context: Context, account: Account, userData: Bundle): Boolean {
|
||||
val accountManager = AccountManager.get(context)
|
||||
userData.keySet().forEach { key ->
|
||||
val stored = accountManager.getUserData(account, key)
|
||||
val expected = userData.getString(key)
|
||||
if (stored != expected) {
|
||||
Logger.log.warning("Stored user data \"$stored\" differs from expected data \"$expected\" for $key")
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
Loaded 100 of 432 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user