Compare commits
955 Commits
v2.0
...
v3.2-beta3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c746aa4ddf | ||
|
|
ec6b2dd16a | ||
|
|
1e57b83a66 | ||
|
|
32b0c8355b | ||
|
|
8e2bee7d36 | ||
|
|
3c202d35d5 | ||
|
|
d82341f310 | ||
|
|
77c00414c1 | ||
|
|
4b26ab1f34 | ||
|
|
9582e08d0e | ||
|
|
0cdb2d1ff3 | ||
|
|
31b75cffa3 | ||
|
|
71111172cb | ||
|
|
357e7cca24 | ||
|
|
a652d5bccf | ||
|
|
c794e1ffbb | ||
|
|
9350818029 | ||
|
|
9f0db15bca | ||
|
|
73e2d554fb | ||
|
|
fd44aea3ce | ||
|
|
c0cf9194b7 | ||
|
|
ca3b308018 | ||
|
|
c9ccbb73ac | ||
|
|
9ee65a229d | ||
|
|
27e5504de8 | ||
|
|
d3e851cb58 | ||
|
|
453200ab1e | ||
|
|
c51677b001 | ||
|
|
aeb0dd70f8 | ||
|
|
47874bf7eb | ||
|
|
e0f90bb311 | ||
|
|
055fdeaf4c | ||
|
|
cc092daf72 | ||
|
|
9acbb49a27 | ||
|
|
c536a8a50c | ||
|
|
d3bed790ab | ||
|
|
2a24e05161 | ||
|
|
9e08e73fc6 | ||
|
|
80df1eecaa | ||
|
|
4e062bc621 | ||
|
|
02a1899109 | ||
|
|
59180cda06 | ||
|
|
74cb4963e4 | ||
|
|
0e4e655d51 | ||
|
|
76020736bb | ||
|
|
1e14bbfe42 | ||
|
|
6f5363ef7a | ||
|
|
cd85c4b05a | ||
|
|
4bef2420df | ||
|
|
9f052f6231 | ||
|
|
877d06abba | ||
|
|
822720e597 | ||
|
|
24c583b784 | ||
|
|
c3f6791baa | ||
|
|
a09f1575c2 | ||
|
|
63898da6e3 | ||
|
|
558ced82da | ||
|
|
b59f6c8b7e | ||
|
|
85beb90b2f | ||
|
|
e732aa7ebd | ||
|
|
eb38791d61 | ||
|
|
447e7c3bdf | ||
|
|
a81eb8be51 | ||
|
|
c0e2488ef8 | ||
|
|
34adafeac2 | ||
|
|
cad2b15a81 | ||
|
|
0670732cc8 | ||
|
|
3089d42c9f | ||
|
|
b6bdf2dfaa | ||
|
|
5a657ba5e0 | ||
|
|
9055b34a1d | ||
|
|
7bf1d5618a | ||
|
|
f7d001ae1d | ||
|
|
8cf8dadb8f | ||
|
|
d5d9f276a3 | ||
|
|
a1509f8ce4 | ||
|
|
af227737f5 | ||
|
|
65ca1c37e8 | ||
|
|
1c2bfc582c | ||
|
|
dbc7ca7cc3 | ||
|
|
6ceca3793c | ||
|
|
2ff46e5219 | ||
|
|
00cf40a694 | ||
|
|
3445d0df54 | ||
|
|
1b0720d798 | ||
|
|
fa1ed29659 | ||
|
|
f01b57bb66 | ||
|
|
94fc3b7cdd | ||
|
|
ffa40e5e13 | ||
|
|
17846c2413 | ||
|
|
cc6194fdf6 | ||
|
|
ebcec6a5a4 | ||
|
|
4977a1ab58 | ||
|
|
7ef847ea35 | ||
|
|
5e0e26a5e5 | ||
|
|
dcdc659882 | ||
|
|
96adb926c6 | ||
|
|
14bdaaa189 | ||
|
|
86bb432625 | ||
|
|
97d657e501 | ||
|
|
4067afb20d | ||
|
|
843f1e099b | ||
|
|
4ed9d8a6a8 | ||
|
|
5b60ccaa2e | ||
|
|
9bdfcb5dc1 | ||
|
|
fd92267c4d | ||
|
|
3c100274ad | ||
|
|
ede9f2a472 | ||
|
|
884dbdbd3d | ||
|
|
e267e620ba | ||
|
|
f32006254f | ||
|
|
2f92002fc3 | ||
|
|
a7c814bafd | ||
|
|
e124ce2f70 | ||
|
|
17c9537fc5 | ||
|
|
ad4c7c97c2 | ||
|
|
27675de7ec | ||
|
|
204ca9120d | ||
|
|
061df6a014 | ||
|
|
aab676b7e4 | ||
|
|
7fd2a99edb | ||
|
|
b7893398bd | ||
|
|
07f2c85ae1 | ||
|
|
08cefe9f80 | ||
|
|
0b9aeb05ab | ||
|
|
1d7e4e8d33 | ||
|
|
621c32da2a | ||
|
|
dcb3dd577a | ||
|
|
a07d849c35 | ||
|
|
cea8d54556 | ||
|
|
6189eef55f | ||
|
|
07dc7592b7 | ||
|
|
da42ad63a2 | ||
|
|
a2eabbdcee | ||
|
|
ad185865c4 | ||
|
|
b0a5bfccad | ||
|
|
4303132a38 | ||
|
|
a0da93f910 | ||
|
|
3c38f063c5 | ||
|
|
c18ef6304b | ||
|
|
2542aba3f0 | ||
|
|
75ef28bd1d | ||
|
|
3aa729051f | ||
|
|
49fafb40e3 | ||
|
|
d1dcf889f3 | ||
|
|
91f9baf042 | ||
|
|
453e703bc0 | ||
|
|
0d5b3c2816 | ||
|
|
86744db3b3 | ||
|
|
2965ae4b1e | ||
|
|
3a1edbafbf | ||
|
|
7f5f3e492b | ||
|
|
e7dce774c8 | ||
|
|
25b97e96e1 | ||
|
|
0d66d29380 | ||
|
|
029c4737bd | ||
|
|
5e5d59889f | ||
|
|
bc9aaf04fe | ||
|
|
3ba2922bde | ||
|
|
6277f35db4 | ||
|
|
b0c53fb852 | ||
|
|
ce0e623912 | ||
|
|
d1709df0b6 | ||
|
|
5469dee1f2 | ||
|
|
489f7ac639 | ||
|
|
58ca99198f | ||
|
|
8a46fcedba | ||
|
|
a0816c11d2 | ||
|
|
c06950751b | ||
|
|
cfc0130bec | ||
|
|
213851856e | ||
|
|
b7e60cd143 | ||
|
|
37a299d0f7 | ||
|
|
41d33fac44 | ||
|
|
3dc2aa65df | ||
|
|
43f4d9c05d | ||
|
|
6c0b555ec9 | ||
|
|
8918382003 | ||
|
|
e30c41828f | ||
|
|
224c92cc87 | ||
|
|
4498f9bf03 | ||
|
|
b3c7f1f9ef | ||
|
|
5622d743c3 | ||
|
|
ae4c6e94c8 | ||
|
|
7bdb5c5a97 | ||
|
|
62c0dfbaee | ||
|
|
53f35d5ee3 | ||
|
|
c9dab31067 | ||
|
|
ac3e9fb825 | ||
|
|
8325fdcf86 | ||
|
|
3f2090ecc7 | ||
|
|
e236b07184 | ||
|
|
3905679576 | ||
|
|
2f33374649 | ||
|
|
54222d3328 | ||
|
|
31339f3014 | ||
|
|
6f1d513e54 | ||
|
|
fef8ce2366 | ||
|
|
c9ca84f14b | ||
|
|
53698adda2 | ||
|
|
27c89e762e | ||
|
|
92394bb6af | ||
|
|
eba5280a58 | ||
|
|
c3157f1256 | ||
|
|
5c795950af | ||
|
|
15f5152a7d | ||
|
|
03f54f5402 | ||
|
|
41ba92cc4c | ||
|
|
c267c92a87 | ||
|
|
3111e54d1a | ||
|
|
ebd20866b3 | ||
|
|
a789246925 | ||
|
|
11452caa13 | ||
|
|
7577eb10e8 | ||
|
|
4b579ca419 | ||
|
|
f9a8de29e2 | ||
|
|
d28f5115fb | ||
|
|
2a40432494 | ||
|
|
06d79087b0 | ||
|
|
3e6243901e | ||
|
|
054955b89e | ||
|
|
c28001f0f1 | ||
|
|
c4d7e6857b | ||
|
|
4f237d82fb | ||
|
|
05cb460cf5 | ||
|
|
2a31ebb2a3 | ||
|
|
372ad8b704 | ||
|
|
296f55651e | ||
|
|
fe5c5737ef | ||
|
|
32fe98a196 | ||
|
|
de687eaf52 | ||
|
|
2062802cb6 | ||
|
|
b280941b2b | ||
|
|
049145d0ea | ||
|
|
c1162fce8e | ||
|
|
40fd412064 | ||
|
|
c13243f3ae | ||
|
|
bda2d64ca4 | ||
|
|
f974ca2ffa | ||
|
|
d6cd0faeeb | ||
|
|
33345db0f0 | ||
|
|
6e2d7b10d7 | ||
|
|
b9fb983b97 | ||
|
|
fe738636a2 | ||
|
|
46df7a63c7 | ||
|
|
b4ebfa4fe5 | ||
|
|
9979dc95c2 | ||
|
|
ebf1273c37 | ||
|
|
3bfe33e37a | ||
|
|
6d273f8b0d | ||
|
|
6922a68b77 | ||
|
|
af3a588632 | ||
|
|
285dfe9307 | ||
|
|
bc80e74a83 | ||
|
|
6fe5fafed7 | ||
|
|
058354b9a4 | ||
|
|
16e1d5041f | ||
|
|
4bca002892 | ||
|
|
fba759583d | ||
|
|
3ed16ae5b2 | ||
|
|
888ffc8d90 | ||
|
|
5ff90d8e20 | ||
|
|
edab897732 | ||
|
|
06637b4b47 | ||
|
|
2ff839837a | ||
|
|
e80243b133 | ||
|
|
481eccc4d6 | ||
|
|
2e641a9e9b | ||
|
|
fe8b4b1d24 | ||
|
|
2fc9d2862e | ||
|
|
23a9be403b | ||
|
|
cd9518c619 | ||
|
|
29c0a9b586 | ||
|
|
aac25b3bdc | ||
|
|
31b47a8554 | ||
|
|
f8eb7a6d56 | ||
|
|
f41786f9f1 | ||
|
|
80f31bbc03 | ||
|
|
4c229c81a1 | ||
|
|
0ac6dc8538 | ||
|
|
1a52794bd1 | ||
|
|
5f9f5e2732 | ||
|
|
9c50903fd6 | ||
|
|
aa4b3657e5 | ||
|
|
ed23b4834c | ||
|
|
da81482bad | ||
|
|
f453d06929 | ||
|
|
0d07fe9020 | ||
|
|
ddd3ffb2c2 | ||
|
|
b4b87bc4f5 | ||
|
|
e74a919812 | ||
|
|
ef8931c9c4 | ||
|
|
13e9d7c7b4 | ||
|
|
4c4cbf6f2e | ||
|
|
55a51c0b87 | ||
|
|
e7833e403f | ||
|
|
ee36b10fd4 | ||
|
|
79c4e24816 | ||
|
|
4a8bf47aa3 | ||
|
|
d32a142ef0 | ||
|
|
1a8171a55e | ||
|
|
d5a03d7837 | ||
|
|
bcc8e02d77 | ||
|
|
75124d99bc | ||
|
|
a081ef210b | ||
|
|
1882dee0c2 | ||
|
|
fb7012e7c6 | ||
|
|
bbb32d501f | ||
|
|
338375789a | ||
|
|
123c5906bd | ||
|
|
eeac458d93 | ||
|
|
471722496e | ||
|
|
1ecd16c229 | ||
|
|
1804c4af96 | ||
|
|
5974ffc7f6 | ||
|
|
5073a56fd6 | ||
|
|
1e76a92d02 | ||
|
|
5709fd38d9 | ||
|
|
4bf9ba5f2d | ||
|
|
f5e317a08a | ||
|
|
8936ad7810 | ||
|
|
1d20dbe4e8 | ||
|
|
b247e70d2a | ||
|
|
477ee085fc | ||
|
|
8f62c185ec | ||
|
|
f6144dc0ab | ||
|
|
a958ed48f9 | ||
|
|
3458f786f5 | ||
|
|
db037f1dfb | ||
|
|
c4ee6037ec | ||
|
|
82310b2c0d | ||
|
|
292b72b62f | ||
|
|
4cdf26d674 | ||
|
|
c5c68ad7c9 | ||
|
|
c734e1a1f2 | ||
|
|
459f9da486 | ||
|
|
1fbd532237 | ||
|
|
a67a56e0c5 | ||
|
|
787c5e480a | ||
|
|
38ecd7a4c4 | ||
|
|
17cf4a6289 | ||
|
|
ac8558ce6a | ||
|
|
4db40ac223 | ||
|
|
38ab785382 | ||
|
|
0c5bb19be9 | ||
|
|
310e5a1720 | ||
|
|
9df0db1ec3 | ||
|
|
f6f3ebb48b | ||
|
|
ffe59915d9 | ||
|
|
94a8c4c218 | ||
|
|
efa168b941 | ||
|
|
b8abc60752 | ||
|
|
77d6fc7243 | ||
|
|
f50f86c1ad | ||
|
|
3c07bc3abc | ||
|
|
400b7babbc | ||
|
|
5ff86d809f | ||
|
|
9af246a01a | ||
|
|
d7cefa84f4 | ||
|
|
d7b73a35e1 | ||
|
|
f20d921559 | ||
|
|
cdec545568 | ||
|
|
6af50dbc43 | ||
|
|
7a44f61887 | ||
|
|
4ec98acdd1 | ||
|
|
e0a000cbd8 | ||
|
|
7c8f74ab14 | ||
|
|
03cbe4f665 | ||
|
|
a76829c3dd | ||
|
|
d83a4df524 | ||
|
|
afdfb735bd | ||
|
|
414f771156 | ||
|
|
c690dfd148 | ||
|
|
50775f26ce | ||
|
|
40a1748461 | ||
|
|
63e360fb3a | ||
|
|
d7216f5ffa | ||
|
|
a749a9fd34 | ||
|
|
6951397945 | ||
|
|
6153b3aafe | ||
|
|
325dd41820 | ||
|
|
be94ee19d8 | ||
|
|
d03ef78db9 | ||
|
|
e7e580132f | ||
|
|
a7c62d11ca | ||
|
|
d978e6a17d | ||
|
|
87457bf6d4 | ||
|
|
bff9c87c1f | ||
|
|
d27261f18a | ||
|
|
affce28652 | ||
|
|
b319fef88a | ||
|
|
6369ae1d36 | ||
|
|
8fe093d975 | ||
|
|
e99acb2921 | ||
|
|
88e0c16f83 | ||
|
|
b348f54f95 | ||
|
|
b75f8d140a | ||
|
|
206c2e8aa9 | ||
|
|
e8f14c3745 | ||
|
|
c2e6514a3c | ||
|
|
676f74a528 | ||
|
|
22b9624c0a | ||
|
|
f22dd3e013 | ||
|
|
913b395e56 | ||
|
|
f9974dbb7e | ||
|
|
49fae85852 | ||
|
|
3addab5bc0 | ||
|
|
1bfacff3df | ||
|
|
643c4f04c9 | ||
|
|
517b60aa21 | ||
|
|
98eeaf171e | ||
|
|
9320de54c3 | ||
|
|
24730d1925 | ||
|
|
94fd665b6d | ||
|
|
7d9d9ea57c | ||
|
|
bc1b1ca40f | ||
|
|
4ff8a5eb4e | ||
|
|
517a859dbf | ||
|
|
1b6689133a | ||
|
|
8a18e1e738 | ||
|
|
f241de264d | ||
|
|
2cf73f0f07 | ||
|
|
15008c1a53 | ||
|
|
c90e491739 | ||
|
|
1b5ea52138 | ||
|
|
48478c421f | ||
|
|
f4823f9524 | ||
|
|
2abe164e08 | ||
|
|
fa5856604d | ||
|
|
c88508ea66 | ||
|
|
e1c5fb2bfd | ||
|
|
9536d43047 | ||
|
|
273c23ee44 | ||
|
|
a34b3b1fee | ||
|
|
6157d6e767 | ||
|
|
013729ed73 | ||
|
|
2287b802ce | ||
|
|
0166008ab6 | ||
|
|
739bf3e53d | ||
|
|
e36102721f | ||
|
|
5ef9693ac8 | ||
|
|
19320dda17 | ||
|
|
322f9a0da0 | ||
|
|
ba20d74b64 | ||
|
|
6a512e1f0f | ||
|
|
4300954101 | ||
|
|
2fe19e9342 | ||
|
|
e764d31d18 | ||
|
|
8d0d920033 | ||
|
|
e2fcbbea0b | ||
|
|
1cefa88b98 | ||
|
|
be4a650deb | ||
|
|
ab47b2b222 | ||
|
|
7f301640da | ||
|
|
83656ef497 | ||
|
|
77c54a2150 | ||
|
|
16e3526a66 | ||
|
|
3ae5653da4 | ||
|
|
c02b033f8c | ||
|
|
4afe4837e7 | ||
|
|
56813ad4d6 | ||
|
|
036fc405f4 | ||
|
|
95173dcd7d | ||
|
|
be754d6ede | ||
|
|
20097c0b69 | ||
|
|
0d1c265e97 | ||
|
|
7ce739c271 | ||
|
|
241957f2dd | ||
|
|
3dea33e100 | ||
|
|
f8f60f4b52 | ||
|
|
0d75cf0cdc | ||
|
|
556843df4f | ||
|
|
8b12cb7a29 | ||
|
|
07c5cdc754 | ||
|
|
1d33e4c311 | ||
|
|
7d4689969a | ||
|
|
b863d355f6 | ||
|
|
bcd468f2e5 | ||
|
|
21fdf2cebc | ||
|
|
ea071bbd1a | ||
|
|
bc5f1e935e | ||
|
|
72749addcd | ||
|
|
6abbd019ad | ||
|
|
67b1685d01 | ||
|
|
4bbb2b8419 | ||
|
|
8bdf03bfc5 | ||
|
|
8b43677d01 | ||
|
|
e454fa398a | ||
|
|
f5b1194599 | ||
|
|
e549a25812 | ||
|
|
fb90955b2a | ||
|
|
a7f0161983 | ||
|
|
45efb88b1f | ||
|
|
2c022d46fe | ||
|
|
50c4ee94e2 | ||
|
|
d06d24a13e | ||
|
|
2c5cdf813a | ||
|
|
0250baeeb3 | ||
|
|
05d1970754 | ||
|
|
a97be98b11 | ||
|
|
1625f092fd | ||
|
|
c0ff8f61f9 | ||
|
|
dbfb74a16f | ||
|
|
39b277bc6d | ||
|
|
d09c70f52b | ||
|
|
2f0ee8e230 | ||
|
|
c320094b3b | ||
|
|
5b12015307 | ||
|
|
e7d2c23989 | ||
|
|
80971c52b5 | ||
|
|
23abf7c1f0 | ||
|
|
613b88ad5e | ||
|
|
b09d6c13b5 | ||
|
|
e30ae04534 | ||
|
|
0ed54bb671 | ||
|
|
889ebe160e | ||
|
|
602e14d2bf | ||
|
|
7ed275712e | ||
|
|
3dc3e75721 | ||
|
|
a63e717ca7 | ||
|
|
c0044ae901 | ||
|
|
edabdbadc9 | ||
|
|
47543e448c | ||
|
|
5ff88c5b2b | ||
|
|
eadfa39760 | ||
|
|
2342bc92ca | ||
|
|
f0d150d491 | ||
|
|
949a1916b5 | ||
|
|
efa4b7dc66 | ||
|
|
971be44ffe | ||
|
|
88efd5ab04 | ||
|
|
b44306eaf2 | ||
|
|
abb0850889 | ||
|
|
7760bddca7 | ||
|
|
60527e83fe | ||
|
|
54cc9b39e1 | ||
|
|
be9d404e6c | ||
|
|
fd40b5a4e3 | ||
|
|
abb142d118 | ||
|
|
7a7a940089 | ||
|
|
f0e1e03095 | ||
|
|
2358996940 | ||
|
|
09b42fc940 | ||
|
|
9c796f8226 | ||
|
|
9a635875a1 | ||
|
|
9d598cead7 | ||
|
|
2915a1f2b6 | ||
|
|
5316fb42a5 | ||
|
|
1c3a5ceb09 | ||
|
|
c34ca0f8e6 | ||
|
|
b9f629f6ce | ||
|
|
c6e37fcc58 | ||
|
|
cb8c1f45ad | ||
|
|
f7e1b97a66 | ||
|
|
90a70f39e8 | ||
|
|
9226d867c3 | ||
|
|
216579680d | ||
|
|
fe8b50c76d | ||
|
|
9ab7b3b105 | ||
|
|
b90ec5b64c | ||
|
|
b197ac7da2 | ||
|
|
9aa58d170f | ||
|
|
553b782339 | ||
|
|
8b6376e01d | ||
|
|
5da229ed88 | ||
|
|
a0bb681f48 | ||
|
|
2e3987e6af | ||
|
|
f72588a691 | ||
|
|
56fc2121f8 | ||
|
|
c863d05f3f | ||
|
|
f0ff12ebb9 | ||
|
|
87cb43b0e6 | ||
|
|
2e073897b8 | ||
|
|
bdc032d1b9 | ||
|
|
d07fcdcddc | ||
|
|
5f2f151e14 | ||
|
|
33ddb06272 | ||
|
|
ef9f13d1d6 | ||
|
|
6b9520287b | ||
|
|
3944ab0222 | ||
|
|
103459464c | ||
|
|
be2d10278d | ||
|
|
f4b864b3d0 | ||
|
|
a576701ee3 | ||
|
|
40bca54c09 | ||
|
|
88efbd7a00 | ||
|
|
292eb3bb52 | ||
|
|
bfddf92a3b | ||
|
|
872bfa2f40 | ||
|
|
73d9dff1b7 | ||
|
|
fd84ff37e5 | ||
|
|
d280ce9d63 | ||
|
|
67c7be900a | ||
|
|
c9cd6de476 | ||
|
|
924ef0243d | ||
|
|
62d9668081 | ||
|
|
ab6fa7ecbf | ||
|
|
74b0e5cd31 | ||
|
|
e2960ab572 | ||
|
|
50bd119871 | ||
|
|
c1f3165f5d | ||
|
|
915cd6ec44 | ||
|
|
73e936aeb5 | ||
|
|
190fc020ae | ||
|
|
c40a6bca2f | ||
|
|
3129fd909f | ||
|
|
31c77a1d57 | ||
|
|
380562bd28 | ||
|
|
80b1cb55c7 | ||
|
|
59dd66383e | ||
|
|
44af04e310 | ||
|
|
adf45cb569 | ||
|
|
ab88020c15 | ||
|
|
e27c6fde08 | ||
|
|
3135af78af | ||
|
|
9200ec89e1 | ||
|
|
b37a2ad0e0 | ||
|
|
1df42350cc | ||
|
|
3dbd5e3d18 | ||
|
|
7fdcb3710b | ||
|
|
2f9f4f1d7b | ||
|
|
9fc3921b32 | ||
|
|
4f4a22a14e | ||
|
|
16c44d8ad3 | ||
|
|
6cd2aa783e | ||
|
|
c2e161dac6 | ||
|
|
5a686a9a4c | ||
|
|
7ba98d1c71 | ||
|
|
fee5ab5064 | ||
|
|
6e1b42e6a4 | ||
|
|
c1ce953e3e | ||
|
|
3a6db13df8 | ||
|
|
d1d771b16a | ||
|
|
f986d9807e | ||
|
|
e3f33a5603 | ||
|
|
702888e9bd | ||
|
|
6b41849cae | ||
|
|
9dfb23b1d2 | ||
|
|
3468473f63 | ||
|
|
bc63559a24 | ||
|
|
3689df1385 | ||
|
|
dd3b326f66 | ||
|
|
e2ca9eb7d7 | ||
|
|
b001be1626 | ||
|
|
e09714af32 | ||
|
|
a4a816296b | ||
|
|
65db706e06 | ||
|
|
abd0eb533e | ||
|
|
323cd48a30 | ||
|
|
07b59fb6be | ||
|
|
3e2e17bdb2 | ||
|
|
73c5c9ef64 | ||
|
|
12261a23ea | ||
|
|
e19b045494 | ||
|
|
076962e5ed | ||
|
|
04306ed919 | ||
|
|
93abef95f7 | ||
|
|
54cc3bec9f | ||
|
|
d4324bcf8e | ||
|
|
eff4438e9f | ||
|
|
1f7298f947 | ||
|
|
c823eb6efd | ||
|
|
7e97017c41 | ||
|
|
5e3b98a676 | ||
|
|
8d60ee8d12 | ||
|
|
45af16afa6 | ||
|
|
1b5bb875e0 | ||
|
|
09f946d5f4 | ||
|
|
0342242937 | ||
|
|
46463bc2bc | ||
|
|
3b13c0ef3c | ||
|
|
a409273d8d | ||
|
|
270607084e | ||
|
|
4be57313d7 | ||
|
|
4e4cf7648b | ||
|
|
5d084166f4 | ||
|
|
173d744c2e | ||
|
|
d0e0b5d9e6 | ||
|
|
b2475d2b50 | ||
|
|
d0341ef1d0 | ||
|
|
34a55e68b0 | ||
|
|
9a0cd64f68 | ||
|
|
591a4ad423 | ||
|
|
7b479d270a | ||
|
|
58c44f6213 | ||
|
|
b81a0c1eb6 | ||
|
|
37a28df7d8 | ||
|
|
19796e6669 | ||
|
|
87719f322b | ||
|
|
d40c2f5c56 | ||
|
|
48962d1bfa | ||
|
|
42ba48253f | ||
|
|
d55a1aa8b5 | ||
|
|
dbc4ef9618 | ||
|
|
3bf9be1443 | ||
|
|
e2b5ce7b10 | ||
|
|
3d537b4472 | ||
|
|
64c3ca96ce | ||
|
|
71f73d66f2 | ||
|
|
0e4bb317f5 | ||
|
|
583280cf55 | ||
|
|
018f06fd53 | ||
|
|
bf63605d50 | ||
|
|
9643cb7661 | ||
|
|
46669dd0e9 | ||
|
|
299f50f6d9 | ||
|
|
af7f5e60bf | ||
|
|
d869ecbc0c | ||
|
|
d8536765e1 | ||
|
|
9036f3e130 | ||
|
|
ab284c80df | ||
|
|
89b324b299 | ||
|
|
a5f86b13bd | ||
|
|
3cdfc70692 | ||
|
|
29eb1fda9f | ||
|
|
3c00520a09 | ||
|
|
d20aa562ad | ||
|
|
550612a282 | ||
|
|
ef496f3986 | ||
|
|
f4e7fd957d | ||
|
|
4f6051a03b | ||
|
|
670dc5ae9b | ||
|
|
7d6a052e74 | ||
|
|
a7f59ded3f | ||
|
|
67646e4ce0 | ||
|
|
d8346fa24a | ||
|
|
54a41f02a4 | ||
|
|
62d989d40a | ||
|
|
533abe80a1 | ||
|
|
4c8fa249cd | ||
|
|
0551140f90 | ||
|
|
a0bfe7a784 | ||
|
|
45d6bf1eae | ||
|
|
856f0bdf53 | ||
|
|
27831a0d4d | ||
|
|
39fa8a1a14 | ||
|
|
13ca1a7ef9 | ||
|
|
71804a59bb | ||
|
|
37b3ee879d | ||
|
|
57cb80768d | ||
|
|
b6d378d346 | ||
|
|
9ff53fbb1e | ||
|
|
eac976379a | ||
|
|
d444d95f12 | ||
|
|
af61ac38c8 | ||
|
|
2e9f1d2d66 | ||
|
|
2262685465 | ||
|
|
4df37759d7 | ||
|
|
fd9e626593 | ||
|
|
9f890a8c8d | ||
|
|
1e0a146dc4 | ||
|
|
8836bf89a8 | ||
|
|
dcbbc9a545 | ||
|
|
319845f5b1 | ||
|
|
dd7f1399f3 | ||
|
|
8de6ebbe8f | ||
|
|
82c5d424a7 | ||
|
|
bdb15e0845 | ||
|
|
14b5e66192 | ||
|
|
636dd1b86a | ||
|
|
152624a984 | ||
|
|
3b9e95d4e3 | ||
|
|
e64a5f408c | ||
|
|
09e53e898d | ||
|
|
d0f879820b | ||
|
|
02f9843d47 | ||
|
|
1c303e43eb | ||
|
|
cfc7f96f83 | ||
|
|
47c1e0b8ea | ||
|
|
c5958de17b | ||
|
|
b890c73d04 | ||
|
|
13b749974b | ||
|
|
f65ac7275e | ||
|
|
0210ba0ad9 | ||
|
|
d5a9a65209 | ||
|
|
b16c7679b8 | ||
|
|
c4ecc28d66 | ||
|
|
b638c2584b | ||
|
|
4be6a25eb6 | ||
|
|
797bb78d20 | ||
|
|
ee696d233e | ||
|
|
a1d2b2ab59 | ||
|
|
4cb60ca78c | ||
|
|
e2c47bbe92 | ||
|
|
53e333ee22 | ||
|
|
a3181d35b4 | ||
|
|
4948471068 | ||
|
|
ed1acb47c7 | ||
|
|
c73d47b1ec | ||
|
|
b306933f95 | ||
|
|
dba55d2f2a | ||
|
|
d20f0986d9 | ||
|
|
1bc6880dc0 | ||
|
|
8d0e4c6a46 | ||
|
|
1da8543e24 | ||
|
|
29792d915a | ||
|
|
9654a23fd1 | ||
|
|
4f8031a3a4 | ||
|
|
eb6fa1ab7f | ||
|
|
29bf7ad204 | ||
|
|
18e2a07415 | ||
|
|
49d1011b4a | ||
|
|
9b6c9b6c03 | ||
|
|
953ffa6afe | ||
|
|
e0a0f92c1e | ||
|
|
ce493865e7 | ||
|
|
77c9126be4 | ||
|
|
9bb56d4f58 | ||
|
|
908a3be678 | ||
|
|
75dbbbf4ed | ||
|
|
2599a15515 | ||
|
|
15fe102049 | ||
|
|
6f55e51fef | ||
|
|
1bdb004daf | ||
|
|
30e83f45a5 | ||
|
|
7c8a8ee9fc | ||
|
|
8747deeea7 | ||
|
|
63d2a7d0ef | ||
|
|
d89e0a392f | ||
|
|
bb6ce6a355 | ||
|
|
f59fd7205d | ||
|
|
0dd1fa47b8 | ||
|
|
a067ff03b3 | ||
|
|
a652a230c6 | ||
|
|
c2a616fe52 | ||
|
|
1ac573d403 | ||
|
|
80a8ce6f8c | ||
|
|
33e45d3ae9 | ||
|
|
f701a63e27 | ||
|
|
5594d2b284 | ||
|
|
dcd99f7bca | ||
|
|
df086e7ab5 | ||
|
|
5b31d06dcf | ||
|
|
a39564a179 | ||
|
|
18d8162074 | ||
|
|
27a4c47c35 | ||
|
|
1bed502e67 | ||
|
|
d2dd27f99a | ||
|
|
2d83ed6be4 | ||
|
|
de608312a2 | ||
|
|
0b1bd35517 | ||
|
|
44fb3713a4 | ||
|
|
ce2e7e24b1 | ||
|
|
fa574aaa8d | ||
|
|
82aa7012d3 | ||
|
|
0789134948 | ||
|
|
d4bd96d846 | ||
|
|
038b106b37 | ||
|
|
5a7aeb3eba | ||
|
|
a33e70162c | ||
|
|
3245b47635 | ||
|
|
7b965769c9 | ||
|
|
91f62614ab | ||
|
|
c875b4b3ae | ||
|
|
286ceeea3f | ||
|
|
8b026d7e16 | ||
|
|
2f97830f28 | ||
|
|
75827d89c5 | ||
|
|
1899c86494 | ||
|
|
4a7b6cf546 | ||
|
|
0b83c1ad2f | ||
|
|
a9380a8e1b | ||
|
|
b756a5f54c | ||
|
|
762c379c54 | ||
|
|
8f8421e65e | ||
|
|
0e67da5718 | ||
|
|
cf8b1e97bd | ||
|
|
61885b41d9 | ||
|
|
f74cecdc1f | ||
|
|
d8ffd83e1e | ||
|
|
666233136d | ||
|
|
eae5631d8a | ||
|
|
dc614bf733 | ||
|
|
22c9603b3c | ||
|
|
073f9230ba | ||
|
|
3948e83f83 | ||
|
|
12664652e5 | ||
|
|
d1162abf4c | ||
|
|
a93fa72d5a | ||
|
|
3c8c3fe9b8 | ||
|
|
a46f2d118c | ||
|
|
dc0221dabb | ||
|
|
a5e80d2268 | ||
|
|
dbd200cb6c | ||
|
|
aacff632b7 | ||
|
|
e4861b0a68 | ||
|
|
8d1b6da197 | ||
|
|
bf21c1338e | ||
|
|
9452d6667a | ||
|
|
6967773690 | ||
|
|
73b95379fe | ||
|
|
9f89173638 | ||
|
|
77a0201327 | ||
|
|
21c3f81a8b | ||
|
|
035dc4f0b2 | ||
|
|
48fec240cf | ||
|
|
82a6faa8de | ||
|
|
a4bd6d6a07 | ||
|
|
b91743239a | ||
|
|
611dff15bb | ||
|
|
cdd56d88fb | ||
|
|
198c53698d | ||
|
|
3924b72a40 | ||
|
|
6520a4dff8 | ||
|
|
9e62c44bef | ||
|
|
9df01a7477 | ||
|
|
c62eb6cd0e | ||
|
|
8465df483e | ||
|
|
2366356922 | ||
|
|
5655aad59a | ||
|
|
65ddab6ed5 | ||
|
|
d914addbb2 | ||
|
|
b9a4489f3c | ||
|
|
9d3b9c963a | ||
|
|
72d981f361 | ||
|
|
6eeb484c56 | ||
|
|
cd7332b67c | ||
|
|
1a8ff69750 | ||
|
|
991221f0ee | ||
|
|
ef4267e173 | ||
|
|
ba029e3da4 | ||
|
|
4270807e2a | ||
|
|
8456d078f1 | ||
|
|
aa693cebb9 | ||
|
|
219a7ed38f | ||
|
|
2aa90b4f9c | ||
|
|
26f9352411 | ||
|
|
279c51c4a6 | ||
|
|
98f4dd4c87 | ||
|
|
5e1cb21d55 | ||
|
|
aa6e43cc94 | ||
|
|
992e83fdb1 | ||
|
|
cb73c1ebf7 | ||
|
|
09d8f63515 | ||
|
|
4f4cf25027 | ||
|
|
de4e81977a | ||
|
|
10bbfcabcb | ||
|
|
b5d295e57d | ||
|
|
49555c34d8 | ||
|
|
9b6903572a | ||
|
|
f16d64d636 | ||
|
|
bceb5b643a | ||
|
|
7e783feecf | ||
|
|
f9040f65fe | ||
|
|
b73a825ac9 | ||
|
|
67104b96fd | ||
|
|
e2c8fc9662 | ||
|
|
40e46b721f | ||
|
|
30e6f44e44 | ||
|
|
fd06c00fca | ||
|
|
307c234f4a | ||
|
|
d6dda6fbeb | ||
|
|
b4cb8234f1 | ||
|
|
a5fb9fd214 | ||
|
|
3bbbba15b3 |
31
.gitlab-ci.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
image: registry.gitlab.com/bitfireat/docker-android-emulator:latest
|
||||
|
||||
before_script:
|
||||
- git submodule update --init --recursive
|
||||
- export GRADLE_USER_HOME=`pwd`/.gradle; chmod +x gradlew
|
||||
|
||||
cache:
|
||||
paths:
|
||||
- .gradle/
|
||||
|
||||
test:
|
||||
tags:
|
||||
- privileged
|
||||
script:
|
||||
- start-emulator.sh
|
||||
- ./gradlew app:check app:connectedCheck
|
||||
artifacts:
|
||||
paths:
|
||||
- app/build/outputs/lint-results-debug.html
|
||||
- app/build/reports
|
||||
- build/reports
|
||||
|
||||
pages:
|
||||
script:
|
||||
- ./gradlew app:dokka
|
||||
- mkdir public && mv app/build/dokka public
|
||||
artifacts:
|
||||
paths:
|
||||
- public
|
||||
only:
|
||||
- master-ose
|
||||
9
.gitmodules
vendored
@@ -1,12 +1,9 @@
|
||||
[submodule "dav4android"]
|
||||
path = dav4android
|
||||
url = ../dav4android.git
|
||||
[submodule "ical4android"]
|
||||
path = ical4android
|
||||
url = ../ical4android.git
|
||||
url = https://gitlab.com/bitfireAT/ical4android.git
|
||||
[submodule "vcard4android"]
|
||||
path = vcard4android
|
||||
url = ../vcard4android.git
|
||||
url = https://gitlab.com/bitfireAT/vcard4android.git
|
||||
[submodule "cert4android"]
|
||||
path = cert4android
|
||||
url = ../cert4android.git
|
||||
url = https://gitlab.com/bitfireAT/cert4android.git
|
||||
|
||||
47
CONTRIBUTING.md
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
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.
|
||||
|
||||
37
README.md
Normal file
@@ -0,0 +1,37 @@
|
||||

|
||||
|
||||
DAVx⁵
|
||||
========
|
||||
|
||||
Please see the [DAVx⁵ Web site](https://www.davx5.com) for
|
||||
comprehensive information about DAVx⁵.
|
||||
|
||||
DAVx⁵ is licensed under the [GPLv3 License](LICENSE).
|
||||
|
||||
News and updates: [@davx5app](https://twitter.com/davx5app) on Twitter
|
||||
|
||||
Help, discussion, feature requests, bug reports and "issues": [DAVx⁵ forums](https://www.davx5.com/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).**
|
||||
|
||||
Generated KDoc: https://bitfireAT.gitlab.io/davx5-ose/dokka/app/
|
||||
|
||||
Parts of DAVx⁵ have been outsourced into these libraries:
|
||||
|
||||
* [cert4android](https://gitlab.com/bitfireAT/cert4android) – custom certificate management
|
||||
* [dav4jvm](https://gitlab.com/bitfireAT/dav4jvm) – WebDAV/CalDav/CardDAV framework
|
||||
* [ical4android](https://gitlab.com/bitfireAT/ical4android) – iCalendar processing and Calendar Provider access
|
||||
* [vcard4android](https://gitlab.com/bitfireAT/vcard4android) – vCard processing and Contacts Provider access
|
||||
|
||||
|
||||
USED THIRD-PARTY LIBRARIES
|
||||
==========================
|
||||
|
||||
Those libraries are used by DAVx⁵ (alphabetically):
|
||||
|
||||
* [Color Picker](https://github.com/jaredrummler/ColorPicker) – [Apache License, Version 2.0](https://github.com/jaredrummler/ColorPicker/LICENSE)
|
||||
* [dnsjava](http://www.xbill.org/dnsjava/) – [BSD License](http://www.xbill.org/dnsjava/dnsjava-current/LICENSE)
|
||||
* [ez-vcard](https://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)
|
||||
@@ -1,9 +0,0 @@
|
||||
|
||||
flavor directory
|
||||
|
||||
gplay main + davdroid + gplay
|
||||
icloud main + icloud
|
||||
managed main + managed
|
||||
soldupe main + soldupe
|
||||
standard main + davdroid
|
||||
|
||||
202
app/build.gradle
@@ -7,161 +7,143 @@
|
||||
*/
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'com.mikepenz.aboutlibraries.plugin'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-android-extensions'
|
||||
apply plugin: 'org.jetbrains.dokka-android'
|
||||
|
||||
ext {
|
||||
baseVersionName = '2.0'
|
||||
}
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'org.jetbrains.dokka'
|
||||
|
||||
android {
|
||||
compileSdkVersion 27
|
||||
buildToolsVersion '28.0.1'
|
||||
compileSdkVersion 29
|
||||
buildToolsVersion '29.0.2'
|
||||
|
||||
defaultConfig {
|
||||
applicationId "at.bitfire.davdroid"
|
||||
resValue "string", "packageID", applicationId
|
||||
|
||||
versionCode 241
|
||||
versionCode 302000002
|
||||
buildConfigField "long", "buildTime", System.currentTimeMillis() + "L"
|
||||
|
||||
minSdkVersion 19 // Android 4.4
|
||||
targetSdkVersion 27 // Android 8.1
|
||||
minSdkVersion 21 // Android 5.0
|
||||
targetSdkVersion 29 // Android 10.0
|
||||
|
||||
buildConfigField "boolean", "customCerts", "false"
|
||||
buildConfigField "boolean", "customCertsUI", "true"
|
||||
buildConfigField "String", "userAgent", "\"DAVx5\""
|
||||
|
||||
// when using this, make sure that notification icons are real bitmaps
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
flavorDimensions "type"
|
||||
compileOptions {
|
||||
// enable because ical4android requires desugaring
|
||||
coreLibraryDesugaringEnabled true
|
||||
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
|
||||
buildFeatures.dataBinding = true
|
||||
|
||||
flavorDimensions "distribution"
|
||||
productFlavors {
|
||||
standard {
|
||||
dimension "type"
|
||||
versionName baseVersionName
|
||||
|
||||
buildConfigField "boolean", "customCerts", "true"
|
||||
}
|
||||
managed {
|
||||
dimension "type"
|
||||
versionName "$baseVersionName-mgd"
|
||||
|
||||
applicationId "com.davdroid.managed"
|
||||
resValue "string", "packageID", applicationId
|
||||
|
||||
buildConfigField "boolean", "customCerts", "true"
|
||||
buildConfigField "boolean", "customCertsUI", "false"
|
||||
minSdkVersion 21
|
||||
}
|
||||
|
||||
gplay {
|
||||
dimension "type"
|
||||
versionName "$baseVersionName-gplay"
|
||||
|
||||
buildConfigField "boolean", "customCerts", "true"
|
||||
}
|
||||
icloud {
|
||||
dimension "type"
|
||||
versionName "$baseVersionName-icloud"
|
||||
|
||||
applicationId "at.bitfire.cloudsync"
|
||||
resValue "string", "packageID", applicationId
|
||||
}
|
||||
soldupe {
|
||||
dimension "type"
|
||||
versionName "$baseVersionName-soldupe"
|
||||
|
||||
applicationId "com.soldupe.cloudsync"
|
||||
resValue "string", "packageID", applicationId
|
||||
minSdkVersion 21
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
standard.java.srcDirs = [ "src/davdroid/java" ]
|
||||
standard.res.srcDirs = [ "src/davdroid/res" ]
|
||||
gplay.java.srcDirs = [ "src/gplay/java", "src/davdroid/java" ]
|
||||
gplay.res.srcDirs = [ "src/gplay/res", "src/davdroid/res" ]
|
||||
androidTest.java.srcDirs = [ "src/androidTest/java", "src/espressoTest/java" ]
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
bitfire {
|
||||
storeFile file("${System.env.HOME}/Entwicklung/GooglePlay/bitfire.jks")
|
||||
storePassword '***REMOVED***'
|
||||
keyAlias 'bitfire'
|
||||
keyPassword '***REMOVED***'
|
||||
}
|
||||
soldupe {
|
||||
storeFile file("${System.env.HOME}/Entwicklung/GooglePlay/soldupe.jks")
|
||||
storePassword 'hei8eePh'
|
||||
keyAlias 'soldupe'
|
||||
keyPassword 'ocaip6oZ'
|
||||
versionName "3.2-beta3-ose"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
minifyEnabled false
|
||||
}
|
||||
release {
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules-release.pro'
|
||||
|
||||
signingConfig signingConfigs.bitfire
|
||||
productFlavors.soldupe.signingConfig signingConfigs.soldupe
|
||||
shrinkResources true
|
||||
}
|
||||
}
|
||||
|
||||
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 "OnClick" // doesn't recognize Kotlin onClick methods
|
||||
disable 'RtlEnabled'
|
||||
disable 'RtlHardcoded'
|
||||
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 'Typos'
|
||||
}
|
||||
packagingOptions {
|
||||
exclude 'META-INF/DEPENDENCIES'
|
||||
exclude 'META-INF/LICENSE'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':cert4android')
|
||||
implementation project(':dav4android')
|
||||
implementation project(':ical4android')
|
||||
implementation project(':vcard4android')
|
||||
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.7"
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.9'
|
||||
|
||||
implementation 'com.android.support:appcompat-v7:27.1.1'
|
||||
implementation 'com.android.support:cardview-v7:27.1.1'
|
||||
implementation 'com.android.support:design:27.1.1'
|
||||
implementation 'com.android.support:preference-v14:27.1.1'
|
||||
implementation 'androidx.appcompat:appcompat:1.2.0-rc01'
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta7'
|
||||
implementation 'androidx.core:core-ktx:1.3.0'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.2.5'
|
||||
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
|
||||
implementation 'androidx.paging:paging-runtime-ktx:2.1.2'
|
||||
implementation 'androidx.preference:preference:1.1.1'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'com.google.android:flexbox:1.1.0'
|
||||
implementation 'com.google.android.material:material:1.2.0-beta01'
|
||||
|
||||
implementation 'com.github.yukuku:ambilwarna:2.0.1'
|
||||
implementation 'com.mikepenz:aboutlibraries:6.0.9'
|
||||
def room_version = '2.2.5'
|
||||
implementation "androidx.room:room-runtime:$room_version"
|
||||
implementation "androidx.room:room-ktx:$room_version"
|
||||
kapt "androidx.room:room-compiler:$room_version"
|
||||
|
||||
implementation 'com.squareup.okhttp3:logging-interceptor:3.11.0'
|
||||
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'
|
||||
implementation 'dnsjava:dnsjava:2.1.8'
|
||||
implementation 'org.apache.commons:commons-lang3:3.7'
|
||||
implementation 'org.apache.commons:commons-collections4:4.1'
|
||||
//noinspection GradleDependency - dnsjava 3+ needs Java 8/Android 7
|
||||
implementation 'dnsjava:dnsjava:2.1.9'
|
||||
implementation 'org.apache.commons:commons-collections4:4.4'
|
||||
//noinspection GradleDependency - commons-lang 3.10+ needs Java 8/Android 7
|
||||
implementation 'org.apache.commons:commons-lang3:3.9'
|
||||
implementation 'org.apache.commons:commons-text:1.8'
|
||||
|
||||
// for tests
|
||||
androidTestImplementation 'com.android.support.test:runner:1.0.2'
|
||||
androidTestImplementation 'com.android.support.test:rules:1.0.2'
|
||||
androidTestImplementation 'junit:junit:4.12'
|
||||
androidTestImplementation 'com.squareup.okhttp3:mockwebserver:3.11.0'
|
||||
androidTestImplementation 'androidx.test:runner:1.2.0'
|
||||
androidTestImplementation 'androidx.test:rules:1.2.0'
|
||||
androidTestImplementation 'junit:junit:4.13'
|
||||
androidTestImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}"
|
||||
|
||||
testImplementation 'junit:junit:4.12'
|
||||
testImplementation 'com.squareup.okhttp3:mockwebserver:3.11.0'
|
||||
testImplementation 'junit:junit:4.13'
|
||||
testImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}"
|
||||
}
|
||||
|
||||
28
app/proguard-rules-release.pro
Normal file
@@ -0,0 +1,28 @@
|
||||
|
||||
# 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
|
||||
|
||||
# okhttp
|
||||
-keepclassmembers class okhttp3.internal.Util.** { *; }
|
||||
|
||||
# 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);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
|
||||
# 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 javax.cache.** # no JCache support in Android
|
||||
-dontwarn net.fortuna.ical4j.model.**
|
||||
-dontwarn org.codehaus.groovy.**
|
||||
-dontwarn org.apache.log4j.** # ignore warnings from log4j dependency
|
||||
-keep class net.fortuna.ical4j.** { *; } # keep all model classes (properties/factories, created at runtime)
|
||||
-keep class org.threeten.bp.** { *; } # keep ThreeTen (for time zone processing)
|
||||
|
||||
# okhttp
|
||||
-dontwarn okio.**
|
||||
-dontwarn javax.annotation.Nullable
|
||||
-dontwarn javax.annotation.ParametersAreNonnullByDefault
|
||||
-dontwarn org.conscrypt.**
|
||||
|
||||
# dnsjava
|
||||
-dontwarn sun.net.spi.nameservice.** # not available on Android
|
||||
|
||||
# DAVdroid + libs
|
||||
-keep class at.bitfire.** { *; } # all DAVdroid code is required
|
||||
@@ -1,128 +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.support.test.InstrumentationRegistry.getInstrumentation
|
||||
import at.bitfire.cert4android.CustomCertManager
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.ArrayUtils.contains
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.net.Socket
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyStore
|
||||
import java.security.Principal
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509ExtendedKeyManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
class CustomTlsSocketFactoryTest {
|
||||
|
||||
private lateinit var certMgr: CustomCertManager
|
||||
private lateinit var factory: CustomTlsSocketFactory
|
||||
private val server = MockWebServer()
|
||||
|
||||
@Before
|
||||
fun startServer() {
|
||||
certMgr = CustomCertManager(getInstrumentation().context, false, true)
|
||||
factory = CustomTlsSocketFactory(null, certMgr)
|
||||
server.start()
|
||||
}
|
||||
|
||||
@After
|
||||
fun stopServer() {
|
||||
server.shutdown()
|
||||
certMgr.close()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testSendClientCertificate() {
|
||||
var public: X509Certificate? = null
|
||||
javaClass.classLoader.getResourceAsStream("sample.crt").use {
|
||||
public = CertificateFactory.getInstance("X509").generateCertificate(it) as? X509Certificate
|
||||
}
|
||||
assertNotNull(public)
|
||||
|
||||
val keyFactory = KeyFactory.getInstance("RSA")
|
||||
val private = keyFactory.generatePrivate(PKCS8EncodedKeySpec(readResource("sample.key")))
|
||||
assertNotNull(private)
|
||||
|
||||
val keyStore = KeyStore.getInstance("AndroidKeyStore")
|
||||
keyStore.load(null)
|
||||
val alias = "sample"
|
||||
keyStore.setKeyEntry(alias, private, null, arrayOf(public))
|
||||
assertTrue(keyStore.containsAlias(alias))
|
||||
|
||||
val trustManagerFactory = TrustManagerFactory.getInstance("X509")
|
||||
trustManagerFactory.init(null as KeyStore?)
|
||||
val trustManager = trustManagerFactory.trustManagers.first() as X509TrustManager
|
||||
|
||||
val factory = CustomTlsSocketFactory(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?) =
|
||||
arrayOf(public).takeIf { forAlias == alias }
|
||||
|
||||
override fun getPrivateKey(forAlias: String?) =
|
||||
private.takeIf { forAlias == alias }
|
||||
}, trustManager)
|
||||
|
||||
/* known client cert test URLs (thanks!):
|
||||
* - https://prod.idrix.eu/secure/
|
||||
* - https://server.cryptomix.com/secure/
|
||||
*/
|
||||
val client = OkHttpClient.Builder()
|
||||
.sslSocketFactory(factory, trustManager)
|
||||
.build()
|
||||
client.newCall(Request.Builder()
|
||||
.get()
|
||||
.url("https://prod.idrix.eu/secure/")
|
||||
.build()).execute().use { response ->
|
||||
assertTrue(response.isSuccessful)
|
||||
assertTrue(response.body()!!.string().contains("CN=User Cert,O=Internet Widgits Pty Ltd,ST=Some-State,C=CA"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpgradeTLS() {
|
||||
val s = factory.createSocket(server.hostName, server.port)
|
||||
assertTrue(s is SSLSocket)
|
||||
val ssl = s as SSLSocket
|
||||
|
||||
assertFalse(contains(ssl.enabledProtocols, "SSLv3"))
|
||||
assertTrue(contains(ssl.enabledProtocols, "TLSv1"))
|
||||
assertTrue(contains(ssl.enabledProtocols, "TLSv1.1"))
|
||||
assertTrue(contains(ssl.enabledProtocols, "TLSv1.2"))
|
||||
}
|
||||
|
||||
|
||||
private fun readResource(name: String): ByteArray {
|
||||
this.javaClass.classLoader.getResourceAsStream(name).use {
|
||||
return IOUtils.toByteArray(it)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,13 +8,11 @@
|
||||
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.support.test.filters.SmallTest
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.property.ResourceType
|
||||
import androidx.test.filters.SmallTest
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.property.ResourceType
|
||||
import at.bitfire.davdroid.HttpClient
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
@@ -22,7 +20,7 @@ import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class CollectionInfoTest {
|
||||
class CollectionTest {
|
||||
|
||||
private lateinit var httpClient: HttpClient
|
||||
private val server = MockWebServer()
|
||||
@@ -40,7 +38,7 @@ class CollectionInfoTest {
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testFromDavResource() {
|
||||
fun testFromDavResponseAddressBook() {
|
||||
// r/w address book
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
@@ -55,16 +53,24 @@ class CollectionInfoTest {
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
var info: CollectionInfo? = null
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = CollectionInfo(response)
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(CollectionInfo.Type.ADDRESS_BOOK, info?.type)
|
||||
assertFalse(info!!.readOnly)
|
||||
assertEquals("My Contacts", info?.displayName)
|
||||
assertEquals("My Contacts Description", info?.description)
|
||||
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)
|
||||
@@ -81,50 +87,48 @@ class CollectionInfoTest {
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
info = null
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = CollectionInfo(response)
|
||||
}
|
||||
assertEquals(CollectionInfo.Type.CALENDAR, info?.type)
|
||||
assertTrue(info!!.readOnly)
|
||||
assertNull(info?.displayName)
|
||||
assertEquals("My Calendar", info?.description)
|
||||
assertEquals(0xFFFF0000.toInt(), info?.color)
|
||||
assertEquals("tzdata", info?.timeZone)
|
||||
assertTrue(info!!.supportsVEVENT)
|
||||
assertTrue(info!!.supportsVTODO)
|
||||
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
|
||||
fun testFromDB() {
|
||||
val values = ContentValues()
|
||||
values.put(Collections.ID, 1)
|
||||
values.put(Collections.SERVICE_ID, 1)
|
||||
values.put(Collections.TYPE, CollectionInfo.Type.CALENDAR.name)
|
||||
values.put(Collections.URL, "http://example.com")
|
||||
values.put(Collections.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)
|
||||
@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>"))
|
||||
|
||||
val info = CollectionInfo(values)
|
||||
assertEquals(CollectionInfo.Type.CALENDAR, info.type)
|
||||
assertEquals(1.toLong(), info.id)
|
||||
assertEquals(1.toLong(), info.serviceID)
|
||||
assertEquals(HttpUrl.parse("http://example.com/"), info.url)
|
||||
assertTrue(info.readOnly)
|
||||
assertEquals("display name", info.displayName)
|
||||
assertEquals("description", info.description)
|
||||
assertEquals(0xFFFF0000.toInt(), info.color)
|
||||
assertEquals("tzdata", info.timeZone)
|
||||
assertTrue(info.supportsVEVENT)
|
||||
assertTrue(info.supportsVTODO)
|
||||
assertTrue(info.selected)
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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, url= "https://example.com/1".toHttpUrl())
|
||||
val entry3 = HomeSet(id=3, serviceId=service.id, url= "https://example.com/3".toHttpUrl())
|
||||
val oldItems = listOf(
|
||||
entry1,
|
||||
HomeSet(id=2, serviceId=service.id, 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,
|
||||
url= "https://example.com/2".toHttpUrl(), displayName="Updated Entry")
|
||||
newItems[updated.url] = updated
|
||||
|
||||
val created = HomeSet(id=4, serviceId=service.id, 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,39 +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 at.bitfire.davdroid.App
|
||||
import junit.framework.Assert.assertEquals
|
||||
import junit.framework.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultsProviderTest {
|
||||
|
||||
private val provider: Provider = DefaultsProvider()
|
||||
|
||||
@Test
|
||||
fun testHas() {
|
||||
assertEquals(Pair(false, true), provider.has("notExisting"))
|
||||
assertEquals(Pair(true, true), provider.has(App.OVERRIDE_PROXY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGet() {
|
||||
assertEquals(Pair("localhost", true), provider.getString(App.OVERRIDE_PROXY_HOST))
|
||||
assertEquals(Pair(8118, true), provider.getInt(App.OVERRIDE_PROXY_PORT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPutRemove() {
|
||||
assertEquals(Pair(false, true), provider.isWritable(App.OVERRIDE_PROXY))
|
||||
assertFalse(provider.putBoolean(App.OVERRIDE_PROXY, true))
|
||||
assertFalse(provider.remove(App.OVERRIDE_PROXY))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class SettingsManagerTest {
|
||||
|
||||
lateinit var settingsManager: SettingsManager
|
||||
|
||||
@Before
|
||||
fun initialize() {
|
||||
settingsManager = SettingsManager.getInstance(InstrumentationRegistry.getInstrumentation().targetContext)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testContainsKey() {
|
||||
assertFalse(settingsManager.containsKey("notExisting"))
|
||||
|
||||
// provided by DefaultsProvider
|
||||
assertTrue(settingsManager.containsKey(Settings.OVERRIDE_PROXY))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,44 +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.support.test.InstrumentationRegistry
|
||||
import android.support.test.InstrumentationRegistry.getTargetContext
|
||||
import at.bitfire.davdroid.App
|
||||
import junit.framework.Assert.assertFalse
|
||||
import junit.framework.Assert.assertTrue
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class SettingsTest {
|
||||
|
||||
lateinit var settings: Settings.Stub
|
||||
|
||||
@Before
|
||||
fun init() {
|
||||
InstrumentationRegistry.getContext().isRestricted
|
||||
settings = Settings.getInstance(getTargetContext())!!
|
||||
}
|
||||
|
||||
@After
|
||||
fun shutdown() {
|
||||
settings.close()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testHas() {
|
||||
assertFalse(settings.has("notExisting"))
|
||||
|
||||
// provided by DefaultsProvider
|
||||
assertTrue(settings.has(App.OVERRIDE_PROXY))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,11 +8,11 @@
|
||||
|
||||
package at.bitfire.davdroid.ui.setup
|
||||
|
||||
import android.support.test.InstrumentationRegistry.getTargetContext
|
||||
import android.support.test.filters.SmallTest
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.property.AddressbookHomeSet
|
||||
import at.bitfire.dav4android.property.ResourceType
|
||||
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
|
||||
@@ -44,18 +44,20 @@ class DavResourceFinderTest {
|
||||
|
||||
lateinit var finder: DavResourceFinder
|
||||
lateinit var client: HttpClient
|
||||
lateinit var loginInfo: LoginInfo
|
||||
lateinit var loginModel: LoginModel
|
||||
|
||||
@Before
|
||||
fun initServerAndClient() {
|
||||
server.setDispatcher(TestDispatcher())
|
||||
server.dispatcher = TestDispatcher()
|
||||
server.start()
|
||||
|
||||
loginInfo = LoginInfo(URI.create("/"), Credentials("mock", "12345"))
|
||||
finder = DavResourceFinder(getTargetContext(), loginInfo)
|
||||
loginModel = LoginModel()
|
||||
loginModel.baseURI = URI.create("/")
|
||||
loginModel.credentials = Credentials("mock", "12345")
|
||||
|
||||
finder = DavResourceFinder(InstrumentationRegistry.getInstrumentation().targetContext, loginModel)
|
||||
client = HttpClient.Builder()
|
||||
.addAuthentication(null, loginInfo.credentials)
|
||||
.addAuthentication(null, loginModel.credentials!!)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -122,21 +124,31 @@ class DavResourceFinderTest {
|
||||
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(rq: RecordedRequest): MockResponse {
|
||||
if (!checkAuth(rq)) {
|
||||
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 = rq.path
|
||||
val path = request.path!!
|
||||
|
||||
if (rq.method.equals("OPTIONS", true)) {
|
||||
if (request.method.equals("OPTIONS", true)) {
|
||||
val dav = when {
|
||||
path.startsWith(PATH_CALDAV) -> "calendar-access"
|
||||
path.startsWith(PATH_CARDDAV) -> "addressbook"
|
||||
@@ -147,7 +159,7 @@ class DavResourceFinderTest {
|
||||
if (dav != null)
|
||||
response.addHeader("DAV", dav)
|
||||
return response
|
||||
} else if (rq.method.equals("PROPFIND", true)) {
|
||||
} else if (request.method.equals("PROPFIND", true)) {
|
||||
val props: String?
|
||||
when (path) {
|
||||
PATH_CALDAV,
|
||||
@@ -165,14 +177,21 @@ class DavResourceFinderTest {
|
||||
" <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'>" +
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav' xmlns:CAL='urn:ietf:params:xml:ns:caldav'>" +
|
||||
"<response>" +
|
||||
" <href>${rq.path}</href>" +
|
||||
" <href>${request.path}</href>" +
|
||||
" <propstat><prop>$props</prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>")
|
||||
|
||||
BIN
app/src/androidTest/res/drawable-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
app/src/androidTest/res/drawable-ldpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
app/src/androidTest/res/drawable-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
app/src/androidTest/res/drawable-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
@@ -1,24 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEEzCCAfsCAQEwDQYJKoZIhvcNAQEFBQAwRjELMAkGA1UEBhMCQ0ExEzARBgNV
|
||||
BAgMClNvbWUtU3RhdGUxEDAOBgNVBAoMB0NBIENlcnQxEDAOBgNVBAMMB0NBIENl
|
||||
cnQwHhcNMTgwMTEzMjAyOTI5WhcNMTkwMTEzMjAyOTI5WjBZMQswCQYDVQQGEwJD
|
||||
QTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0
|
||||
cyBQdHkgTHRkMRIwEAYDVQQDDAlVc2VyIENlcnQwggEiMA0GCSqGSIb3DQEBAQUA
|
||||
A4IBDwAwggEKAoIBAQDqOyHAeG4psE/f6i/eTfwbhn6j7WaFXxZiSOWwpQZmzRrx
|
||||
MrfkABJCk0X7KNgCaJcmBkG9G1Ri4HfKrxvJFswMXknlq+0ulGBk7oDnZM+pihuX
|
||||
3D9VCWMMkCqYhLCGADj2zB2mkX4LpcMRi6XoOetKURE/vcIy7rSLAtJM6ZRdftfh
|
||||
2ZxnautS1Tyujh9Au3NI/+Of80tT/nA+oBJQeT1fB/ga1OQlZP5kjSaA7IPiIbTz
|
||||
QBO+r898MvqK/lwsvOYnWAp7TY03z+vPfCs0zjijZEl9Wrl0hW6o5db5kU1v5bcr
|
||||
p87hxFJsGD2HIr2y6kvYfL2hn+h9iANyYdRnUgapAgMBAAEwDQYJKoZIhvcNAQEF
|
||||
BQADggIBAHANsiJITedXPyp89lVMEmGY3zKtOqgQ3tqjvjlNt2sdPnj7wmZbmrNd
|
||||
sa90S/UwOn8PzEFOVxYy1BPlljlEjtjmc4OHMcm4P4Zv36uawHilmK8V+zT59gCK
|
||||
ftB5FP2TLFUFi2X9o8J06d0xJRE77uewN155NV4RmPuP4b/tMmeixoQppHqLqEr5
|
||||
lgEUnt3Mh1ctmeFQFJR6lJ01hlB0gdpVHIhzrVLTO3uo8ePLJTmxP6tyKl/HXj9F
|
||||
mpVsKb1kriKwbkGczfw99OUZeUVbTwQOR07r0SrG71B7IuDvxIORnhQc1OUjt7ob
|
||||
wjdaZauAHxpGBRu+hw9Yqaxchk9Gldy1nEjGyyVCD0FU5taXbl8PhBWEDc4U9tI+
|
||||
xVNmPpsSuCsbz3Mjd1YIVRGL99vLrKsQcj+TNM+jJKKRKes3ihl+l/0FwG6UuO7L
|
||||
EvjlUg5hOtYi1D7xuYyMjroGBGh7swYMt6w4eCDbcjzcCkaCi0H2pScM/rLBpDjS
|
||||
LIoGCvZ1LBdi933/iOj1/8dxGZwY6fEgcyiD2n0xAgYIniLWjEZXOMdIK5FNTNga
|
||||
Tswanvp+6Noa4oIu/hl/LXvPMsouaWfSEbRe0Dshi3GpLj3YtEHoN9DHB8bn7jy5
|
||||
34By81GT41m5kq3hWP//x9kSHYSADpbovCbKbElU1qSt6vTVR4nq
|
||||
-----END CERTIFICATE-----
|
||||
@@ -1,68 +0,0 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
|
||||
class DefaultAccountsDrawerHandler: IAccountsDrawerHandler {
|
||||
|
||||
companion object {
|
||||
private const val BETA_FEEDBACK_URI = "mailto:support@davdroid.com?subject=${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} feedback (${BuildConfig.VERSION_CODE})"
|
||||
}
|
||||
|
||||
|
||||
override fun onSettingsChanged(settings: ISettings?, menu: Menu) {
|
||||
if (BuildConfig.VERSION_NAME.contains("-beta") || BuildConfig.VERSION_NAME.contains("-rc"))
|
||||
menu.findItem(R.id.nav_beta_feedback).isVisible = true
|
||||
}
|
||||
|
||||
override fun onNavigationItemSelected(activity: Activity, item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.nav_about ->
|
||||
activity.startActivity(Intent(activity, AboutActivity::class.java))
|
||||
R.id.nav_app_settings ->
|
||||
activity.startActivity(Intent(activity, AppSettingsActivity::class.java))
|
||||
R.id.nav_beta_feedback -> {
|
||||
val intent = Intent(Intent.ACTION_SENDTO, Uri.parse(BETA_FEEDBACK_URI))
|
||||
if (activity.packageManager.resolveActivity(intent, 0) != null)
|
||||
activity.startActivity(intent)
|
||||
}
|
||||
R.id.nav_twitter ->
|
||||
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://twitter.com/davdroidapp")))
|
||||
R.id.nav_website ->
|
||||
activity.startActivity(Intent(Intent.ACTION_VIEW, App.homepageUrl(activity)))
|
||||
R.id.nav_manual ->
|
||||
activity.startActivity(Intent(Intent.ACTION_VIEW, App.homepageUrl(activity)
|
||||
.buildUpon().appendEncodedPath("manual/").build()))
|
||||
R.id.nav_faq ->
|
||||
activity.startActivity(Intent(Intent.ACTION_VIEW, App.homepageUrl(activity)
|
||||
.buildUpon().appendEncodedPath("faq/").build()))
|
||||
R.id.nav_forums ->
|
||||
activity.startActivity(Intent(Intent.ACTION_VIEW, App.homepageUrl(activity)
|
||||
.buildUpon().appendEncodedPath("forums/").build()))
|
||||
R.id.nav_donate ->
|
||||
if (BuildConfig.FLAVOR != App.FLAVOR_GOOGLE_PLAY)
|
||||
activity.startActivity(Intent(Intent.ACTION_VIEW, App.homepageUrl(activity)
|
||||
.buildUpon().appendEncodedPath("donate/").build()))
|
||||
else ->
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui.setup
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.security.KeyChain
|
||||
import android.support.v4.app.Fragment
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.CompoundButton
|
||||
import at.bitfire.dav4android.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import kotlinx.android.synthetic.standard.login_credentials_fragment.view.*
|
||||
import java.net.IDN
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
import java.util.logging.Level
|
||||
|
||||
class DefaultLoginCredentialsFragment: Fragment(), CompoundButton.OnCheckedChangeListener {
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val v = inflater.inflate(R.layout.login_credentials_fragment, container, false)
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
// first call
|
||||
activity?.intent?.let {
|
||||
// we've got initial login data
|
||||
val url = it.getStringExtra(LoginActivity.EXTRA_URL)
|
||||
val username = it.getStringExtra(LoginActivity.EXTRA_USERNAME)
|
||||
val password = it.getStringExtra(LoginActivity.EXTRA_PASSWORD)
|
||||
|
||||
if (url != null) {
|
||||
v.login_type_urlpwd.isChecked = true
|
||||
v.urlpwd_base_url.setText(url)
|
||||
v.urlpwd_user_name.setText(username)
|
||||
v.urlpwd_password.setText(password)
|
||||
} else {
|
||||
v.login_type_email.isChecked = true
|
||||
v.email_address.setText(username)
|
||||
v.email_password.setText(password)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
v.urlcert_select_cert.setOnClickListener {
|
||||
KeyChain.choosePrivateKeyAlias(activity, { alias ->
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
v.urlcert_cert_alias.text = alias
|
||||
v.urlcert_cert_alias.error = null
|
||||
}
|
||||
}, null, null, null, -1, view!!.urlcert_cert_alias.text.toString())
|
||||
}
|
||||
|
||||
v.login.setOnClickListener {
|
||||
validateLoginData()?.let { info ->
|
||||
DetectConfigurationFragment.newInstance(info).show(fragmentManager, null)
|
||||
}
|
||||
}
|
||||
|
||||
// initialize to Login by email
|
||||
onCheckedChanged(v)
|
||||
|
||||
v.login_type_email.setOnCheckedChangeListener(this)
|
||||
v.login_type_urlpwd.setOnCheckedChangeListener(this)
|
||||
v.login_type_urlcert.setOnCheckedChangeListener(this)
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
|
||||
onCheckedChanged(view!!)
|
||||
}
|
||||
|
||||
private fun onCheckedChanged(v: View) {
|
||||
v.login_type_email_details.visibility = if (v.login_type_email.isChecked) View.VISIBLE else View.GONE
|
||||
v.login_type_urlpwd_details.visibility = if (v.login_type_urlpwd.isChecked) View.VISIBLE else View.GONE
|
||||
v.login_type_urlcert_details.visibility = if (v.login_type_urlcert.isChecked) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun validateLoginData(): LoginInfo? {
|
||||
val view = requireNotNull(view)
|
||||
when {
|
||||
// Login with email address
|
||||
view.login_type_email.isChecked -> {
|
||||
var uri: URI? = null
|
||||
var valid = true
|
||||
|
||||
val email = view.email_address.text.toString()
|
||||
if (!email.matches(Regex(".+@.+"))) {
|
||||
view.email_address.error = getString(R.string.login_email_address_error)
|
||||
valid = false
|
||||
} else
|
||||
try {
|
||||
uri = URI("mailto", email, null)
|
||||
} catch (e: URISyntaxException) {
|
||||
view.email_address.error = e.localizedMessage
|
||||
valid = false
|
||||
}
|
||||
|
||||
val password = view.email_password.text.toString()
|
||||
if (password.isEmpty()) {
|
||||
view.email_password.error = getString(R.string.login_password_required)
|
||||
valid = false
|
||||
}
|
||||
|
||||
return if (valid && uri != null)
|
||||
LoginInfo(uri, email, password)
|
||||
else
|
||||
null
|
||||
|
||||
}
|
||||
|
||||
// Login with URL and user name
|
||||
view.login_type_urlpwd.isChecked -> {
|
||||
var valid = true
|
||||
|
||||
val baseUrl = Uri.parse(view.urlpwd_base_url.text.toString())
|
||||
val uri = validateBaseUrl(baseUrl, false) { message ->
|
||||
view.urlpwd_base_url.error = message
|
||||
valid = false
|
||||
}
|
||||
|
||||
val userName = view.urlpwd_user_name.text.toString()
|
||||
if (userName.isBlank()) {
|
||||
view.urlpwd_user_name.error = getString(R.string.login_user_name_required)
|
||||
valid = false
|
||||
}
|
||||
|
||||
val password = view.urlpwd_password.text.toString()
|
||||
if (password.isEmpty()) {
|
||||
view.urlpwd_password.error = getString(R.string.login_password_required)
|
||||
valid = false
|
||||
}
|
||||
|
||||
return if (valid && uri != null)
|
||||
LoginInfo(uri, userName, password)
|
||||
else
|
||||
null
|
||||
}
|
||||
|
||||
// Login with URL and client certificate
|
||||
view.login_type_urlcert.isChecked -> {
|
||||
var valid = true
|
||||
|
||||
val baseUrl = Uri.parse(view.urlcert_base_url.text.toString())
|
||||
val uri = validateBaseUrl(baseUrl, true) { message ->
|
||||
view.urlcert_base_url.error = message
|
||||
valid = false
|
||||
}
|
||||
|
||||
val alias = view.urlcert_cert_alias.text.toString()
|
||||
if (alias.isEmpty()) {
|
||||
view.urlcert_cert_alias.error = ""
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (valid && uri != null)
|
||||
return LoginInfo(uri, certificateAlias = alias)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun validateBaseUrl(baseUrl: Uri, httpsRequired: Boolean, reportError: (String) -> Unit): URI? {
|
||||
var uri: URI? = null
|
||||
val scheme = baseUrl.scheme
|
||||
if ((!httpsRequired && scheme.equals("http", true)) || scheme.equals("https", true)) {
|
||||
var host = baseUrl.host
|
||||
if (host.isNullOrBlank())
|
||||
reportError(getString(R.string.login_url_host_name_required))
|
||||
else
|
||||
try {
|
||||
host = IDN.toASCII(host)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Constants.log.log(Level.WARNING, "Host name not conforming to RFC 3490", e)
|
||||
}
|
||||
|
||||
val path = baseUrl.encodedPath
|
||||
val port = baseUrl.port
|
||||
try {
|
||||
uri = URI(baseUrl.scheme, null, host, port, path, null, null)
|
||||
} catch (e: URISyntaxException) {
|
||||
reportError(e.localizedMessage)
|
||||
}
|
||||
} else
|
||||
reportError(getString(if (httpsRequired)
|
||||
R.string.login_url_must_be_https
|
||||
else
|
||||
R.string.login_url_must_be_http_or_https))
|
||||
return uri
|
||||
}
|
||||
|
||||
|
||||
class Factory: ILoginCredentialsFragment {
|
||||
|
||||
override fun getFragment() = DefaultLoginCredentialsFragment()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ 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
|
||||
-->
|
||||
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<!-- We don't want the keyboard up when the user arrives in this initial screen -->
|
||||
<View android:layout_height="0dp"
|
||||
android:layout_width="0dp"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true"
|
||||
android:contentDescription="@null"
|
||||
android:importantForAccessibility="no" tools:ignore="UnusedAttribute">
|
||||
<requestFocus/>
|
||||
</View>
|
||||
|
||||
<ScrollView android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_margin="@dimen/activity_margin">
|
||||
|
||||
<RadioGroup
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:animateLayoutChanges="true">
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/login_type_email"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="true"
|
||||
android:text="@string/login_type_email"
|
||||
android:paddingLeft="14dp" tools:ignore="RtlSymmetry"
|
||||
style="@style/login_type_headline"/>
|
||||
<LinearLayout
|
||||
android:id="@+id/login_type_email_details"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="16dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<android.support.design.widget.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
<android.support.design.widget.TextInputEditText
|
||||
android:id="@+id/email_address"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/login_email_address"
|
||||
android:inputType="textEmailAddress"/>
|
||||
</android.support.design.widget.TextInputLayout>
|
||||
<android.support.design.widget.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:passwordToggleEnabled="true">
|
||||
<android.support.design.widget.TextInputEditText
|
||||
android:id="@+id/email_password"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/login_password"
|
||||
android:fontFamily="monospace"
|
||||
android:inputType="textPassword"/>
|
||||
</android.support.design.widget.TextInputLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/login_type_urlpwd"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/login_type_url"
|
||||
android:layout_marginTop="16dp"
|
||||
android:paddingLeft="14dp" tools:ignore="RtlSymmetry"
|
||||
style="@style/login_type_headline"/>
|
||||
<LinearLayout
|
||||
android:id="@+id/login_type_urlpwd_details"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="16dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<android.support.design.widget.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
<android.support.design.widget.TextInputEditText
|
||||
android:id="@+id/urlpwd_base_url"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/login_base_url"
|
||||
android:inputType="textUri"/>
|
||||
</android.support.design.widget.TextInputLayout>
|
||||
<android.support.design.widget.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
<android.support.design.widget.TextInputEditText
|
||||
android:id="@+id/urlpwd_user_name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/login_user_name"
|
||||
android:inputType="textEmailAddress"/>
|
||||
</android.support.design.widget.TextInputLayout>
|
||||
<android.support.design.widget.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:passwordToggleEnabled="true">
|
||||
<android.support.design.widget.TextInputEditText
|
||||
android:id="@+id/urlpwd_password"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="monospace"
|
||||
android:inputType="textPassword"
|
||||
android:hint="@string/login_password"/>
|
||||
</android.support.design.widget.TextInputLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/login_type_urlcert"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/login_type_url_certificate"
|
||||
android:layout_marginTop="16dp"
|
||||
android:paddingLeft="14dp" tools:ignore="RtlSymmetry"
|
||||
style="@style/login_type_headline"/>
|
||||
<LinearLayout
|
||||
android:id="@+id/login_type_urlcert_details"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="16dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<android.support.design.widget.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
<android.support.design.widget.TextInputEditText
|
||||
android:id="@+id/urlcert_base_url"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/login_base_url"
|
||||
android:inputType="textUri"/>
|
||||
</android.support.design.widget.TextInputLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/urlcert_cert_alias"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingLeft="3dp"
|
||||
android:paddingRight="3dp"
|
||||
style="@style/Base.TextAppearance.AppCompat.Body1"
|
||||
android:textSize="16sp"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/urlcert_select_cert"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
style="@style/Widget.AppCompat.Button.Borderless.Colored"
|
||||
android:text="@string/login_select_certificate"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</RadioGroup>
|
||||
</ScrollView>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
style="@style/stepper_nav_bar">
|
||||
|
||||
<Space
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
style="@style/stepper_nav_button"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/login"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/login_login"
|
||||
style="@style/stepper_nav_button"/>
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
@@ -1,179 +0,0 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!--common strings-->
|
||||
<string name="app_name">DAVdroid</string>
|
||||
<string name="help">Pomoc</string>
|
||||
<string name="manage_accounts">Spravovat účty</string>
|
||||
<string name="please_wait">Chvíli strpení ...</string>
|
||||
<string name="send">Odeslat</string>
|
||||
<!--startup dialogs-->
|
||||
<string name="startup_battery_optimization_disable">Vypnout pro DAVdroid</string>
|
||||
<string name="startup_dont_show_again">Již nezobrazovat</string>
|
||||
<string name="startup_donate">Open Source informace</string>
|
||||
<string name="startup_donate_message">Jsme velice rádi že používáte DAVdroid, software s otevřeným zdrojovým kódem (GPLv3). Vývoj této aplikace je náročný a trval již několik tisíc hodin, velice nás potěší přispějete-li na jeho vývoj.</string>
|
||||
<string name="startup_donate_now">Zobrazit stránku pro obdarování</string>
|
||||
<string name="startup_donate_later">Možná později</string>
|
||||
<string name="startup_google_play_accounts_removed">Informace o chybě DRM Obchodu Play</string>
|
||||
<string name="startup_google_play_accounts_removed_message">Za určitých podmínek může dojít po restartu nebo aktualizaci aplikace DAVdroid k vymazání účtů kvůli chybě DRM Obchodu Play. Pokud jste postiženi touto chybou (ale pouze v tomto případě), nainstalujte prosím z Obchodu Play aplikaci \"DAVdroid JB Workaround\".</string>
|
||||
<string name="startup_opentasks_not_installed">OpenTasks není nainstalován</string>
|
||||
<string name="startup_opentasks_reinstall_davdroid">Po instalaci OpenTasks musíte PŘEINSTALOVAT DAVdroid a přidat znovu své účty (Android chyba).</string>
|
||||
<string name="startup_opentasks_not_installed_install">Nainstalovat OpenTasks</string>
|
||||
<!--AboutActivity-->
|
||||
<string name="about_license_info_no_warranty">Tento program je distribuován BEZ JAKÉKOLIV ZÁRUKY. Je to volně dostupný software a lze jej za určitých podmínek dále distribuovat.</string>
|
||||
<!--global settings-->
|
||||
<string name="logging_davdroid_file_logging">DAVdroid logování do souboru</string>
|
||||
<string name="logging_to_external_storage">Logování do externího úložiště: %s</string>
|
||||
<string name="logging_couldnt_create_file">Nelze vytvořit externí soubor logu: %s</string>
|
||||
<string name="logging_no_external_storage">Externí úložiště nenalezeno</string>
|
||||
<!--AccountsActivity-->
|
||||
<string name="navigation_drawer_open">Otevřít panel navigace</string>
|
||||
<string name="navigation_drawer_close">Zavřít panel navigace</string>
|
||||
<string name="navigation_drawer_subtitle">CalDAV/CardDAV adapter synchronizace</string>
|
||||
<string name="navigation_drawer_about">O aplikaci / Licence</string>
|
||||
<string name="navigation_drawer_settings">Nastavení</string>
|
||||
<string name="navigation_drawer_news_updates">Novinky & aktualizace</string>
|
||||
<string name="navigation_drawer_external_links">Externí odkazy</string>
|
||||
<string name="navigation_drawer_website">Webová stránka</string>
|
||||
<string name="navigation_drawer_faq">FAQ</string>
|
||||
<string name="navigation_drawer_donate">Obdarovat</string>
|
||||
<string name="account_list_empty">Vítejte v aplikaci DAVdroid!\n\nNyní můžete přidat CalDAV/CardDAV účet.</string>
|
||||
<!--DavService-->
|
||||
<string name="dav_service_refresh_failed">Vyhledání služby selhalo</string>
|
||||
<string name="dav_service_refresh_couldnt_refresh">Nelze obnovit seznam sbírky</string>
|
||||
<!--AppSettingsActivity-->
|
||||
<string name="app_settings">Nastavení</string>
|
||||
<string name="app_settings_user_interface">Uživatelské prostředí</string>
|
||||
<string name="app_settings_reset_hints">Resetovat nápovědu</string>
|
||||
<string name="app_settings_reset_hints_summary">Znovu povolí vypnuté texty nápovědy</string>
|
||||
<string name="app_settings_reset_hints_success">Budou zobrazovány všechny texty nápovědy</string>
|
||||
<string name="app_settings_connection">Připojení</string>
|
||||
<string name="app_settings_override_proxy">Přepsat proxy nastavení</string>
|
||||
<string name="app_settings_override_proxy_on">Použít vlastní proxy nastavení</string>
|
||||
<string name="app_settings_override_proxy_off">Použít výchozí systémová proxy nastavení</string>
|
||||
<string name="app_settings_override_proxy_host">HTTP proxy hostname</string>
|
||||
<string name="app_settings_override_proxy_port">HTTP proxy port</string>
|
||||
<string name="app_settings_security">Zabezpečení</string>
|
||||
<string name="app_settings_distrust_system_certs">Nedůvěřovat systémovým certifikátům</string>
|
||||
<string name="app_settings_distrust_system_certs_on">Systémovým a uživatelem přidaným CA nebude důvěřováno</string>
|
||||
<string name="app_settings_distrust_system_certs_off">Systémovým a uživatelem přidaným CA bude důvěřováno (doporučeno)</string>
|
||||
<string name="app_settings_reset_certificates">Resetovat (ne)důvěryhodné certifikáty</string>
|
||||
<string name="app_settings_reset_certificates_summary">Resetovat důvěryhodnost všech vlastních certifikátů</string>
|
||||
<string name="app_settings_reset_certificates_success">Všechny vlastní certifikáty byly resetovány</string>
|
||||
<string name="app_settings_debug">Ladění</string>
|
||||
<string name="app_settings_log_to_external_storage">Logovat do externího souboru</string>
|
||||
<string name="app_settings_log_to_external_storage_on">Logování do externího úložiště (pokud dostupné)</string>
|
||||
<string name="app_settings_log_to_external_storage_off">Logování do externího souboru je vypnuto</string>
|
||||
<string name="app_settings_show_debug_info">Zobrazit ladící informace</string>
|
||||
<string name="app_settings_show_debug_info_details">Zobrazit/sdílet software a detaily konfigurace</string>
|
||||
<!--AccountActivity-->
|
||||
<string name="account_synchronize_now">Synchronizovat nyní</string>
|
||||
<string name="account_synchronizing_now">Probíhá synchronizace</string>
|
||||
<string name="account_settings">Nastavení účtu</string>
|
||||
<string name="account_rename">Přejmenovat účet</string>
|
||||
<string name="account_rename_new_name">Neuložená místní data mohou být vynechána. Po přejmenování je vyžadována nová synchronizace. Nové jméno účtu:</string>
|
||||
<string name="account_rename_rename">Přejmenovat</string>
|
||||
<string name="account_delete">Smazat účet</string>
|
||||
<string name="account_delete_confirmation_title">Opravdu smazat účet?</string>
|
||||
<string name="account_delete_confirmation_text">Všechny místní kopie adresáře, kalendářů a úkolů budou smazány.</string>
|
||||
<string name="account_refresh_address_book_list">Obnovit seznam adresářů</string>
|
||||
<string name="account_create_new_address_book">Vytvořit nový adresář</string>
|
||||
<string name="account_refresh_calendar_list">Obnovit seznam kalendářů</string>
|
||||
<string name="account_create_new_calendar">Vytvořit nový kalendář</string>
|
||||
<!--AddAccountActivity-->
|
||||
<string name="login_title">Přidat účet</string>
|
||||
<string name="login_type_email">Přihlášení s emailovou adresou</string>
|
||||
<string name="login_email_address">Emailová adresa</string>
|
||||
<string name="login_email_address_error">Vyžadován platný email</string>
|
||||
<string name="login_password">Heslo</string>
|
||||
<string name="login_password_required">Vyžadováno heslo</string>
|
||||
<string name="login_type_url">Přihlášení s URL a uživatelským jménem</string>
|
||||
<string name="login_url_must_be_http_or_https">URL musí začínat na http(s)://</string>
|
||||
<string name="login_url_host_name_required">Vyžadováno hostname</string>
|
||||
<string name="login_user_name">Uživatelské jméno</string>
|
||||
<string name="login_user_name_required">Vyžadováno uživatelské jméno</string>
|
||||
<string name="login_base_url">Základní URL</string>
|
||||
<string name="login_login">Login</string>
|
||||
<string name="login_back">Zpět</string>
|
||||
<string name="login_create_account">Vytvořit účet</string>
|
||||
<string name="login_account_name">Jméno účtu</string>
|
||||
<string name="login_account_name_info">Pro jméno účtu použijte svou emailovou adresu, protože Android bude brát jméno účtu jako údaj pro ORGANIZÁTORA vytvořených událostí. Nelze mít dva účty stejného jména.</string>
|
||||
<string name="login_account_contact_group_method">Metoda seskupování kontaktů:</string>
|
||||
<string name="login_account_name_required">Vyžadováno jméno účtu</string>
|
||||
<string name="login_account_not_created">Účet nelze vytvořit</string>
|
||||
<string name="login_configuration_detection">Vyhledání konfigurace</string>
|
||||
<string name="login_querying_server">Chvíli strpení, probíhá dotazování serveru...</string>
|
||||
<string name="login_no_caldav_carddav">Nelze nalézt službu CalDAV nebo CardDAV.</string>
|
||||
<!--AccountSettingsActivity-->
|
||||
<string name="settings_title">Nastavení: %s</string>
|
||||
<string name="settings_authentication">Ověření</string>
|
||||
<string name="settings_username">Uživatelské jméno</string>
|
||||
<string name="settings_enter_username">Zadat uživatelské jméno</string>
|
||||
<string name="settings_password">Heslo</string>
|
||||
<string name="settings_password_summary">Aktualizovat heslo dle svého serveru.</string>
|
||||
<string name="settings_enter_password">Vložit své heslo:</string>
|
||||
<string name="settings_sync">Synchronizace</string>
|
||||
<string name="settings_sync_interval_contacts">Interval synchronizace kontaktů</string>
|
||||
<string name="settings_sync_summary_manually">Pouze manuálně</string>
|
||||
<string name="settings_sync_summary_periodically" tools:ignore="PluralsCandidate">Každých %d minut a ihned při lokálních změnách</string>
|
||||
<string name="settings_sync_interval_calendars">Interval synchronizace kalendáře</string>
|
||||
<string name="settings_sync_interval_tasks">Interval synchronizace úkolů</string>
|
||||
<string name="settings_sync_wifi_only">Synchronizovat pouze přes WiFi</string>
|
||||
<string name="settings_sync_wifi_only_on">Synchronizace omezena na WiFi připojení</string>
|
||||
<string name="settings_sync_wifi_only_off">Druh připojení není brán v potaz</string>
|
||||
<string name="settings_carddav">CardDAV</string>
|
||||
<string name="settings_contact_group_method">Metoda seskupování kontaktů</string>
|
||||
<string-array name="settings_contact_group_method_values">
|
||||
<item>GROUP_VCARDS</item>
|
||||
<item>CATEGORIES</item>
|
||||
</string-array>
|
||||
<string-array name="settings_contact_group_method_entries">
|
||||
<item>Skupiny jsou oddělené soubory VCard</item>
|
||||
<item>Skupiny jsou kategorie na kontakt</item>
|
||||
</string-array>
|
||||
<string name="settings_caldav">CalDAV</string>
|
||||
<string name="settings_sync_time_range_past">Časový limit pro staré události</string>
|
||||
<string name="settings_sync_time_range_past_none">Synchronizovat všechny události</string>
|
||||
<plurals name="settings_sync_time_range_past_days">
|
||||
<item quantity="one">Ignorovat události starší než 1 den</item>
|
||||
<item quantity="few">Ignorovat události starší než %d dny</item>
|
||||
<item quantity="many">Ignorovat události starší než %d dnů</item>
|
||||
<item quantity="other">Ignorovat události starší než %d dnů</item>
|
||||
</plurals>
|
||||
<string name="settings_sync_time_range_past_message">Události z minulosti starší než vyznačený počet dnů budou ignorovány (lze zadat 0). Ponechte prázdné pro synchronizaci všech událostí.</string>
|
||||
<string name="settings_manage_calendar_colors">Spravovat barvy kalendářů</string>
|
||||
<string name="settings_manage_calendar_colors_on">Barvy kalendářů spravuje DAVdroid</string>
|
||||
<string name="settings_manage_calendar_colors_off">Barvy kalendářů nespravuje DAVdroid</string>
|
||||
<!--collection management-->
|
||||
<string name="create_addressbook">Vytvořit adresář</string>
|
||||
<string name="create_addressbook_display_name_hint">Můj adresář</string>
|
||||
<string name="create_calendar">Vytvořit CalDAV sbírku</string>
|
||||
<string name="create_calendar_display_name_hint">Můj kalendář</string>
|
||||
<string name="create_calendar_time_zone">Časová zóna:</string>
|
||||
<string name="create_calendar_type">Typ sbírky:</string>
|
||||
<string name="create_calendar_type_only_events">Kalendář (pouze události)</string>
|
||||
<string name="create_calendar_type_only_tasks">Seznam úkolů (pouze úkoly)</string>
|
||||
<string name="create_calendar_type_events_and_tasks">Kombinovaná (události a úkoly)</string>
|
||||
<string name="create_collection_color">Nastavit barvu sbírky</string>
|
||||
<string name="create_collection_creating">Vytváření sbírky</string>
|
||||
<string name="create_collection_display_name">Zobrazit jméno (nadpis) této sbírky:</string>
|
||||
<string name="create_collection_display_name_required">Nadpis je vyžadován</string>
|
||||
<string name="create_collection_description">Popis (volitelný):</string>
|
||||
<string name="create_collection_home_set">Domácí sbírka:</string>
|
||||
<string name="create_collection_create">Vytvořit</string>
|
||||
<string name="delete_collection">Smazat sbírku</string>
|
||||
<string name="delete_collection_confirm_title">Jste si jisti?</string>
|
||||
<string name="delete_collection_confirm_warning">Tato sbírka (%s) a všechna její data budou odstraněna ze serveru.</string>
|
||||
<string name="delete_collection_deleting_collection">Mazání sbírky</string>
|
||||
<!--ExceptionInfoFragment-->
|
||||
<string name="exception">Došlo k chybě.</string>
|
||||
<string name="exception_httpexception">Došlo k HTTP chybě.</string>
|
||||
<string name="exception_ioexception">Došlo k I/O chybě.</string>
|
||||
<string name="exception_show_details">Zobrazit detaily</string>
|
||||
<!--sync adapters and DebugInfoActivity-->
|
||||
<string name="debug_info_title">Ladící informace</string>
|
||||
<string name="sync_error_permissions">DAVdroid oprávnění</string>
|
||||
<string name="sync_error_permissions_text">Vyžadována dodatečná oprávnění</string>
|
||||
<!--cert4android-->
|
||||
<string name="certificate_notification_connection_security">DAVdroid: Zabezpečení připojení</string>
|
||||
<string name="trust_certificate_unknown_certificate_found">DAVdroid nalezl neznámý certifikát. Chcete mu důvěřovat?</string>
|
||||
</resources>
|
||||
@@ -1,206 +0,0 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!--common strings-->
|
||||
<string name="app_name">DAVdroid</string>
|
||||
<string name="account_title_address_book">Carnet d\'adresses DAVdroid</string>
|
||||
<string name="address_books_authority_title">Carnets d\'adresses</string>
|
||||
<string name="help">Aide</string>
|
||||
<string name="manage_accounts">Gestion des comptes</string>
|
||||
<string name="please_wait">patientez ...</string>
|
||||
<string name="send">Envoyer</string>
|
||||
<!--startup dialogs-->
|
||||
<string name="startup_battery_optimization_disable">Désactiver pour DAVdroid</string>
|
||||
<string name="startup_dont_show_again">Ne plus afficher</string>
|
||||
<string name="startup_donate">Open-Source Information</string>
|
||||
<string name="startup_donate_message">Nous sommes heureux que vous utilisez DAVdroid, qui est un logiciel open-source (GPLv3). Parce que développer DAVdroid est un travail difficile et nous a pris de nombreuses heures, s\'il vous plaît envisager de faire un don.</string>
|
||||
<string name="startup_donate_now">Faire un don</string>
|
||||
<string name="startup_donate_later">Plus tard</string>
|
||||
<string name="startup_google_play_accounts_removed">Erreur information Play Store DRM</string>
|
||||
<string name="startup_google_play_accounts_removed_message">Dans certaines conditions, Play Store DRM peut provoquer la disparition de tous les comptes DAVdroid après un redémarrage ou après la mise à niveau de DAVdroid. Si vous êtes concerné par ce problème (et seulement alors), s\'il vous plaît installer \"DAVdroid JB Solution\" du Play Store.</string>
|
||||
<string name="startup_opentasks_not_installed">L\'application OpenTasks n\'est pas installée</string>
|
||||
<string name="startup_opentasks_reinstall_davdroid">Après l\'installation OpenTasks, vous devez RE-INSTALLER DAVdroid et ajoutez vos comptes à nouveau (bug Android).</string>
|
||||
<string name="startup_opentasks_not_installed_install">Installer OpenTasks</string>
|
||||
<!--AboutActivity-->
|
||||
<string name="about_license_info_no_warranty">Ce programme est fourni sans AUCUNE GARANTIE. C\'est un logiciel libre, et vous êtes en droit de le redistribuer sous certaines conditions.</string>
|
||||
<!--global settings-->
|
||||
<string name="logging_davdroid_file_logging">DAVdroid fichier de journalisation</string>
|
||||
<string name="logging_to_external_storage">Se connecter au stockage externe: %s</string>
|
||||
<string name="logging_couldnt_create_file">Impossible de créer le fichier journal externe: %s</string>
|
||||
<string name="logging_no_external_storage">Stockage externe introuvable</string>
|
||||
<!--AccountsActivity-->
|
||||
<string name="navigation_drawer_open">Ouvrir le tiroir de navigation</string>
|
||||
<string name="navigation_drawer_close">Fermer le tiroir de navigation</string>
|
||||
<string name="navigation_drawer_subtitle">Adaptateur de synchronisation CalDAV/CardDAV</string>
|
||||
<string name="navigation_drawer_about">A propos / Licence</string>
|
||||
<string name="navigation_drawer_settings">Paramètres</string>
|
||||
<string name="navigation_drawer_news_updates">Actualités & mises à jour</string>
|
||||
<string name="navigation_drawer_external_links">Liens externes</string>
|
||||
<string name="navigation_drawer_website">Site Web</string>
|
||||
<string name="navigation_drawer_faq">Foire aux questions</string>
|
||||
<string name="navigation_drawer_forums">Aide/Forum</string>
|
||||
<string name="navigation_drawer_donate">Faire un don</string>
|
||||
<string name="account_list_empty">Bienvenue sur DAVdroid!\n\nVous pouvez maintenant ajouter un compte CalDAV ou CardDAV.</string>
|
||||
<string name="accounts_global_sync_disabled">La synchronisation automatique globale est désactivée</string>
|
||||
<string name="accounts_global_sync_enable">Activer</string>
|
||||
<!--DavService-->
|
||||
<string name="dav_service_refresh_failed">La détection du service a échoué</string>
|
||||
<string name="dav_service_refresh_couldnt_refresh">Impossible d\'actualiser la liste de collection</string>
|
||||
<!--AppSettingsActivity-->
|
||||
<string name="app_settings">Paramètres</string>
|
||||
<string name="app_settings_user_interface">Interface utilisateur</string>
|
||||
<string name="app_settings_reset_hints">Réinitialiser les astuces</string>
|
||||
<string name="app_settings_reset_hints_summary">Réactiver les astuces qui ont été vues précédemment</string>
|
||||
<string name="app_settings_reset_hints_success">Toutes les astuces seront affichés à nouveau</string>
|
||||
<string name="app_settings_connection">Connexion</string>
|
||||
<string name="app_settings_override_proxy">Ignorer les paramètres proxy</string>
|
||||
<string name="app_settings_override_proxy_on">Utiliser des paramètres proxy personnalisés</string>
|
||||
<string name="app_settings_override_proxy_off">Utiliser les paramètres proxy du système</string>
|
||||
<string name="app_settings_override_proxy_host">Nom de l\'hôte du proxy HTTP</string>
|
||||
<string name="app_settings_override_proxy_port">Port du proxy HTTP</string>
|
||||
<string name="app_settings_security">Sécurité</string>
|
||||
<string name="app_settings_distrust_system_certs">Révoquer les certificats du système</string>
|
||||
<string name="app_settings_distrust_system_certs_on">Les certificats du système et ceux ajoutés par l\'utilisateur ne seront pas dignes de confiance</string>
|
||||
<string name="app_settings_distrust_system_certs_off">Les certificats du système et ceux ajoutés par l\'utilisateur seront dignes de confiance (recommandé)</string>
|
||||
<string name="app_settings_reset_certificates">Réinitialiser les certificats de (non)confiance</string>
|
||||
<string name="app_settings_reset_certificates_summary">Réinitialiser la confiance de tous les certificats personnalisés</string>
|
||||
<string name="app_settings_reset_certificates_success">Tous les certificats personnalisés ont été effacés</string>
|
||||
<string name="app_settings_debug">Débogage</string>
|
||||
<string name="app_settings_log_to_external_storage">Journaliser dans un fichier externe</string>
|
||||
<string name="app_settings_log_to_external_storage_on">Journaliser sur le stockage externe (si disponible)</string>
|
||||
<string name="app_settings_log_to_external_storage_off">Le fichier externe n\'est pas disponible.</string>
|
||||
<string name="app_settings_show_debug_info">Afficher les infos de débogage</string>
|
||||
<string name="app_settings_show_debug_info_details">Voir/partager l\'application et les détails de configuration</string>
|
||||
<!--AccountActivity-->
|
||||
<string name="account_synchronize_now">Synchroniser maintenant</string>
|
||||
<string name="account_synchronizing_now">Synchronisation en cours</string>
|
||||
<string name="account_settings">Paramètres du compte</string>
|
||||
<string name="account_rename">Renommer le compte</string>
|
||||
<string name="account_rename_new_name">Les données locales non enregistrées pourraient être perdues. Une re-synchronisation est nécessaire après avoir renommé le compte. Nouveau nom du compte : </string>
|
||||
<string name="account_rename_rename">Renommer</string>
|
||||
<string name="account_delete">Supprimer le compte</string>
|
||||
<string name="account_delete_confirmation_title">Voulez-vous vraiment supprimer le compte?</string>
|
||||
<string name="account_delete_confirmation_text">Toutes les copies locales des carnets d\'adresses, des calendriers et des listes de tâches seront supprimées.</string>
|
||||
<string name="account_carddav">CardDAV (les carnets d\'adresse) </string>
|
||||
<string name="account_caldav">CalDAV (les agendas) </string>
|
||||
<string name="account_webcal">WebCal (les anciens agenda)</string>
|
||||
<string name="account_calendar">calendrier</string>
|
||||
<string name="account_task_list">liste de tâche</string>
|
||||
<string name="account_refresh_address_book_list">Actualiser le carnet d\'adresses</string>
|
||||
<string name="account_create_new_address_book">Créer un nouveau carnet d\'adresses</string>
|
||||
<string name="account_refresh_calendar_list">Actualiser le calendrier</string>
|
||||
<string name="account_create_new_calendar">Créer un nouveau calendrier</string>
|
||||
<string name="account_no_webcal_handler_found">Aucune application compatible WebCal</string>
|
||||
<string name="account_install_icsdroid">Installer ICSdroid</string>
|
||||
<!--AddAccountActivity-->
|
||||
<string name="login_title">Ajouter un compte</string>
|
||||
<string name="login_type_email">Connexion avec une adresse email</string>
|
||||
<string name="login_email_address">Adresse mail</string>
|
||||
<string name="login_email_address_error">Une adresse e-mail valide est requise</string>
|
||||
<string name="login_password">Mot de passe</string>
|
||||
<string name="login_password_required">Mot de passe requis</string>
|
||||
<string name="login_type_url">Connexion avec une URL et un nom d\'utilisateur</string>
|
||||
<string name="login_url_must_be_http_or_https">L\'URL doit commencer par http(s)://</string>
|
||||
<string name="login_url_host_name_required">Nom d\'hôte requis</string>
|
||||
<string name="login_user_name">Nom d\'utilisateur</string>
|
||||
<string name="login_user_name_required">Nom d\'utilisateur requis</string>
|
||||
<string name="login_base_url">URL de base</string>
|
||||
<string name="login_login">Se connecter</string>
|
||||
<string name="login_back">Retour</string>
|
||||
<string name="login_create_account">Créer un compte</string>
|
||||
<string name="login_account_name">Nom du compte</string>
|
||||
<string name="login_account_name_info">Utilisez votre adresse e-mail comme nom de compte car Android utilisera ce nom en tant que champ ORGANISATEUR pour les événements que vous créerez. Vous ne pouvez pas avoir deux comptes avec le même nom.</string>
|
||||
<string name="login_account_contact_group_method">Méthode pour les contacts de type groupe :</string>
|
||||
<string name="login_account_name_required">Nom du compte requis</string>
|
||||
<string name="login_account_not_created">Le compte n\'a pas pu être créé</string>
|
||||
<string name="login_configuration_detection">Détection de la configuration</string>
|
||||
<string name="login_querying_server">Veuillez patienter, nous interrogeons le serveur ...</string>
|
||||
<string name="login_no_caldav_carddav">Aucun accès possible au service CalDAV ou CardDAV.</string>
|
||||
<!--AccountSettingsActivity-->
|
||||
<string name="settings_title">Paramètres: %s</string>
|
||||
<string name="settings_authentication">Authentification</string>
|
||||
<string name="settings_username">Nom d\'utilisateur</string>
|
||||
<string name="settings_enter_username">Saisissez votre nom d\'utilisateur :</string>
|
||||
<string name="settings_password">Mot de passe</string>
|
||||
<string name="settings_password_summary">Mettre à jour le mot de passe </string>
|
||||
<string name="settings_enter_password">Saisissez votre mot de passe :</string>
|
||||
<string name="settings_sync">Synchronisation</string>
|
||||
<string name="settings_sync_interval_contacts">Intervalle de synchronisation des carnets d\'adresses</string>
|
||||
<string name="settings_sync_summary_manually">Manuellement</string>
|
||||
<string name="settings_sync_summary_periodically" tools:ignore="PluralsCandidate">Toutes les %d minutes et immédiatement après un changement local</string>
|
||||
<string name="settings_sync_interval_calendars">Intervalle de synchronisation des agendas</string>
|
||||
<string name="settings_sync_interval_tasks">Intervalle de synchronisation des tâches</string>
|
||||
<string-array name="settings_sync_interval_names">
|
||||
<item>Manuellement</item>
|
||||
<item>Tous les quarts d\'heure</item>
|
||||
<item>Toutes les demi-heures</item>
|
||||
<item>Toutes les heures</item>
|
||||
<item>Toutes les deux heures</item>
|
||||
<item>Toutes les quatre heures</item>
|
||||
<item>Une fois par jour</item>
|
||||
</string-array>
|
||||
<string name="settings_sync_wifi_only">Synchronisation en Wifi seulement</string>
|
||||
<string name="settings_sync_wifi_only_on">La synchronisation est limitée aux connexions WiFi</string>
|
||||
<string name="settings_sync_wifi_only_off">Le type de connexion n\'est pas pris en charge</string>
|
||||
<string name="settings_sync_wifi_only_ssids">Restriction WiFi SSID</string>
|
||||
<string name="settings_sync_wifi_only_ssids_on">Synchronisation possible seulement en %s</string>
|
||||
<string name="settings_sync_wifi_only_ssids_off">Toutes les connexions WiFi seront utilisées</string>
|
||||
<string name="settings_sync_wifi_only_ssids_message">Liste des points d\'accès WiFi (SSID) autorisés, séparés par des virgules. (Laissez vide pour tous)</string>
|
||||
<string name="settings_carddav">CardDAV</string>
|
||||
<string name="settings_contact_group_method">Méthode pour les contacts de type groupe</string>
|
||||
<string-array name="settings_contact_group_method_values">
|
||||
<item>GROUP_VCARDS</item>
|
||||
<item>CATEGORIES</item>
|
||||
</string-array>
|
||||
<string-array name="settings_contact_group_method_entries">
|
||||
<item>Les groupes sont des VCards indépendantes</item>
|
||||
<item>Les groupes sont des catégories pour chacun des contacts</item>
|
||||
</string-array>
|
||||
<string name="settings_caldav">CalDAV</string>
|
||||
<string name="settings_sync_time_range_past">Limite des événements passés</string>
|
||||
<string name="settings_sync_time_range_past_none">Tous les événements seront synchronisés</string>
|
||||
<plurals name="settings_sync_time_range_past_days">
|
||||
<item quantity="one">Les événements de plus d’un jour passé seront ignorés</item>
|
||||
<item quantity="other">Les événements de plus de %d jours passés seront ignorés</item>
|
||||
</plurals>
|
||||
<string name="settings_sync_time_range_past_message">Les événements antérieurs à ce nombre de jours seront ignorés (peut être 0). Laissez vide pour synchroniser tous les événements.</string>
|
||||
<string name="settings_manage_calendar_colors">Choisir couleur du calendrier</string>
|
||||
<string name="settings_manage_calendar_colors_on">Les couleurs de calendrier sont gérées par DAVdroid</string>
|
||||
<string name="settings_manage_calendar_colors_off">Les couleurs de calendrier ne sont pas gérées par DAVdroid</string>
|
||||
<string name="settings_event_colors">Couleur associée aux événements</string>
|
||||
<string name="settings_event_colors_on">Synchroniser la couleur associée aux événements</string>
|
||||
<string name="settings_event_colors_off">Ne pas synchroniser la couleur associée aux événements</string>
|
||||
<string name="settings_event_colors_off_confirm">Modifier la couleur associée aux événements peut affecter les valeurs déjà synchronisées.</string>
|
||||
<!--collection management-->
|
||||
<string name="create_addressbook">Créer un carnet d\'adresses</string>
|
||||
<string name="create_addressbook_display_name_hint">Mon carnet d\'adresses</string>
|
||||
<string name="create_calendar">Créer une collection CalDAV</string>
|
||||
<string name="create_calendar_display_name_hint">Mon calendrier</string>
|
||||
<string name="create_calendar_time_zone">Fuseau horaire:</string>
|
||||
<string name="create_calendar_type">Type de collection:</string>
|
||||
<string name="create_calendar_type_only_events">Calendrier (événements seulement)</string>
|
||||
<string name="create_calendar_type_only_tasks">Liste de tâches (tâches seulement)</string>
|
||||
<string name="create_calendar_type_events_and_tasks">Fusionner (événements et tâches)</string>
|
||||
<string name="create_collection_color">Choisir une couleur pour la collection</string>
|
||||
<string name="create_collection_creating">Création collection</string>
|
||||
<string name="create_collection_display_name">Le nom affiché (titre) pour cette collection:</string>
|
||||
<string name="create_collection_display_name_required">Titre requis</string>
|
||||
<string name="create_collection_description">Description (facultatif)</string>
|
||||
<string name="create_collection_home_set">Accueil:</string>
|
||||
<string name="create_collection_create">Créer</string>
|
||||
<string name="delete_collection">Supprimer la collection</string>
|
||||
<string name="delete_collection_confirm_title">Êtes-vous sur?</string>
|
||||
<string name="delete_collection_confirm_warning">Cette collection (%s) et toutes ses données seront supprimées du serveur.</string>
|
||||
<string name="delete_collection_deleting_collection">Suppression de la collection</string>
|
||||
<!--ExceptionInfoFragment-->
|
||||
<string name="exception">Une erreur est survenue.</string>
|
||||
<string name="exception_httpexception">Une erreur HTTP est survenue.</string>
|
||||
<string name="exception_ioexception">Une erreur I/O est survenue.</string>
|
||||
<string name="exception_show_details">Voir détails</string>
|
||||
<!--sync adapters and DebugInfoActivity-->
|
||||
<string name="debug_info_title">Infos de débogage</string>
|
||||
<string name="sync_error_permissions">Autorisations DAVdroid</string>
|
||||
<string name="sync_error_permissions_text">Autorisations supplémentaires demandées</string>
|
||||
<!--cert4android-->
|
||||
<string name="certificate_notification_connection_security">DAVdroid : Sécurité de la connexion</string>
|
||||
<string name="trust_certificate_unknown_certificate_found">DAVdroid a rencontré un certificat inconnu. Voulez-vous lui faire confiance?</string>
|
||||
</resources>
|
||||
@@ -1,195 +0,0 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!--common strings-->
|
||||
<string name="app_name">DAVdroid</string>
|
||||
<string name="account_title_address_book">DAVdroid Adresboek</string>
|
||||
<string name="address_books_authority_title">Adresboeken</string>
|
||||
<string name="help">Help</string>
|
||||
<string name="manage_accounts">Beheer accounts</string>
|
||||
<string name="please_wait">Een moment geduld...</string>
|
||||
<string name="send">Verzenden</string>
|
||||
<!--startup dialogs-->
|
||||
<string name="startup_battery_optimization_disable">DAVdroid afsluiten</string>
|
||||
<string name="startup_dont_show_again">Niet opnieuw weergeven</string>
|
||||
<string name="startup_donate">Open-Source informatie</string>
|
||||
<string name="startup_donate_message">We zijn blij dat je DAVdroid gebruikt, wat open-source software (GPLv3) is. Omdat de ontwikkeling van DAVdroid hard werk is en duizenden uren in beslag neemt. overweeg alstublieft een donatie.</string>
|
||||
<string name="startup_donate_now">Toon donatie pagina</string>
|
||||
<string name="startup_donate_later">Misschien later</string>
|
||||
<string name="startup_google_play_accounts_removed">Play Store DRM fout-informatie</string>
|
||||
<string name="startup_google_play_accounts_removed_message">Onder bepaalde omstandigheden, kan Play Store DRM ervoor zorgen dat accounts kwijt zijn na een herstart of na een DAVdroid update. Als dit probleem zich bij je voordoet (en alleen dan), Installeer dan \"DAVdroid JB Workaround\" vanuit de Play Store</string>
|
||||
<string name="startup_opentasks_not_installed">OpenTasks niet geinstalleerd</string>
|
||||
<string name="startup_opentasks_reinstall_davdroid">Na installatie van OpenTasks dient u DAVdroid opnieuw te installeren en de accounts toe te voegen (Android bug).</string>
|
||||
<string name="startup_opentasks_not_installed_install">OpenTasks installeren</string>
|
||||
<!--AboutActivity-->
|
||||
<string name="about_license_info_no_warranty">Dit programma kom met ABSOLUUT GEEN GARANTIE. Het is gratis software, en je bent welkom dit te herdistribueren onder bepaalde voorwaarden.</string>
|
||||
<!--global settings-->
|
||||
<string name="logging_davdroid_file_logging">DAVDroid bestand loggen</string>
|
||||
<string name="logging_to_external_storage">Loggen naar externe opslag: %s</string>
|
||||
<string name="logging_couldnt_create_file">Kon extern log bestand niet verwijderen: %s</string>
|
||||
<string name="logging_no_external_storage">Externe opslag niet gevonden</string>
|
||||
<!--AccountsActivity-->
|
||||
<string name="navigation_drawer_open">Open navigatie drawer</string>
|
||||
<string name="navigation_drawer_close">Sluit navigatie drawer</string>
|
||||
<string name="navigation_drawer_subtitle">CalDAV/CardDav Sync adapter</string>
|
||||
<string name="navigation_drawer_about">Over / Licentie</string>
|
||||
<string name="navigation_drawer_beta_feedback">Beta terugkoppeling</string>
|
||||
<string name="navigation_drawer_settings">Instellingen</string>
|
||||
<string name="navigation_drawer_news_updates">Nieuws & updates</string>
|
||||
<string name="navigation_drawer_external_links">Externe links</string>
|
||||
<string name="navigation_drawer_website">Website</string>
|
||||
<string name="navigation_drawer_faq">FAQ</string>
|
||||
<string name="navigation_drawer_forums">Help / Forums</string>
|
||||
<string name="navigation_drawer_donate">Doneren</string>
|
||||
<string name="account_list_empty">Welkom bij DAVdroid!\n\nJe kunt nu een CalDAV/CardDAv account toevoegen.</string>
|
||||
<string name="accounts_global_sync_disabled">Systeembrede automatische synchronisatie is uitgeschakeld</string>
|
||||
<string name="accounts_global_sync_enable">Inschakelen</string>
|
||||
<!--DavService-->
|
||||
<string name="dav_service_refresh_failed">Service herkenning is mislukt</string>
|
||||
<string name="dav_service_refresh_couldnt_refresh">Kon de collectie lijst niet vernieuwen</string>
|
||||
<!--AppSettingsActivity-->
|
||||
<string name="app_settings">Instellingen</string>
|
||||
<string name="app_settings_user_interface">Gebruikers interface</string>
|
||||
<string name="app_settings_reset_hints">Hints resetten </string>
|
||||
<string name="app_settings_reset_hints_summary">Hints die al gezien zijn opnieuw weergeven</string>
|
||||
<string name="app_settings_reset_hints_success">Alle hints worden opnieuw weergegeven</string>
|
||||
<string name="app_settings_connection">Verbinding</string>
|
||||
<string name="app_settings_override_proxy">Proxy instellingen overschrijven</string>
|
||||
<string name="app_settings_override_proxy_on">Eigen proxy instellingen gebruiken</string>
|
||||
<string name="app_settings_override_proxy_off">Systeem proxy instellingen gebruiken</string>
|
||||
<string name="app_settings_override_proxy_host">HTTP proxy beheerder naam</string>
|
||||
<string name="app_settings_override_proxy_port">HTTP proxy poort</string>
|
||||
<string name="app_settings_security">Beveiliging</string>
|
||||
<string name="app_settings_distrust_system_certs">Systeem certificaten niet vertrouwen</string>
|
||||
<string name="app_settings_distrust_system_certs_on">Systeem en CAs van toegevoegde gebruiker wordt niet vertrouwd</string>
|
||||
<string name="app_settings_distrust_system_certs_off">Systeem en CAs van toegevoegde gebruiker wordt vertrouwd (aanbevolen)</string>
|
||||
<string name="app_settings_reset_certificates">Resetten (niet) vertrouwde certificaten</string>
|
||||
<string name="app_settings_reset_certificates_summary">Resetten alle bewerkte certificaten</string>
|
||||
<string name="app_settings_reset_certificates_success">Alle bewerkte certificaten zijn vrijgemaakt</string>
|
||||
<string name="app_settings_debug">Debuggen</string>
|
||||
<string name="app_settings_log_to_external_storage">Log naar extern bestand</string>
|
||||
<string name="app_settings_log_to_external_storage_on">Loggen naar externe opslag (wanneer beschikbaar)</string>
|
||||
<string name="app_settings_log_to_external_storage_off">Extern bestands loggen uitgeschakeld</string>
|
||||
<string name="app_settings_show_debug_info">Debug info tonen</string>
|
||||
<string name="app_settings_show_debug_info_details">Bekijk/deel software configuratie details</string>
|
||||
<!--AccountActivity-->
|
||||
<string name="account_synchronize_now">Synchroniseer nu</string>
|
||||
<string name="account_synchronizing_now">Aan het synchronizeren...</string>
|
||||
<string name="account_settings">Account instellingen</string>
|
||||
<string name="account_rename">Account hernoemen</string>
|
||||
<string name="account_rename_new_name">Niet opgeslagen lokale informatie mag verloren gaan. Synchronisatie is noodzakelijk na hernoemen. Nieuw account naam:</string>
|
||||
<string name="account_rename_rename">Hernoemen</string>
|
||||
<string name="account_delete">Account verwijderen</string>
|
||||
<string name="account_delete_confirmation_title">Account echt verwijderen?</string>
|
||||
<string name="account_delete_confirmation_text">Alle lokale kopieën van adresboeken, agenda\'s en taken worden verwijderd.</string>
|
||||
<string name="account_carddav">CardDAV</string>
|
||||
<string name="account_caldav">CalDAV</string>
|
||||
<string name="account_webcal">Webcal</string>
|
||||
<string name="account_refresh_address_book_list">Adresboeken vernieuwen</string>
|
||||
<string name="account_create_new_address_book">Maak een nieuw adresboek</string>
|
||||
<string name="account_refresh_calendar_list">Agenda\'s vernieuwen</string>
|
||||
<string name="account_create_new_calendar">Maak een nieuwe agenda</string>
|
||||
<string name="account_no_webcal_handler_found">Geen mogelijke Webcal app gevonden</string>
|
||||
<string name="account_install_icsdroid">ICSdroid installeren</string>
|
||||
<!--AddAccountActivity-->
|
||||
<string name="login_title">Account toevoegen</string>
|
||||
<string name="login_type_email">Inloggen met e-mailadres</string>
|
||||
<string name="login_email_address">Email adres</string>
|
||||
<string name="login_email_address_error">Geldig email adres vereist</string>
|
||||
<string name="login_password">Wachtwoord</string>
|
||||
<string name="login_password_required">Wachtwoord vereist</string>
|
||||
<string name="login_type_url">Inloggen met URL en gebruikersnaam</string>
|
||||
<string name="login_url_must_be_http_or_https">URL moet met http(s):// beginnen</string>
|
||||
<string name="login_url_host_name_required">Hostnaam vereist</string>
|
||||
<string name="login_user_name">Gebruikersnaam</string>
|
||||
<string name="login_user_name_required">Gebruikersnaam vereist</string>
|
||||
<string name="login_base_url">Basis URL</string>
|
||||
<string name="login_login">Login</string>
|
||||
<string name="login_back">Terug</string>
|
||||
<string name="login_create_account">Maak een account</string>
|
||||
<string name="login_account_name">Accountnaam</string>
|
||||
<string name="login_account_name_info">Gebruik je email adres als account naam want Android zal je account naam gebruiken als ORGANIZER veld voor gemaakte afspraken. Je kunt geen 2 accounts met dezelfde naam hebben,</string>
|
||||
<string name="login_account_contact_group_method">Contact groep methode:</string>
|
||||
<string name="login_account_name_required">Accountnaam vereist</string>
|
||||
<string name="login_account_not_created">Account kon niet gemaakt worden.</string>
|
||||
<string name="login_configuration_detection">Configuratie detectie</string>
|
||||
<string name="login_querying_server">Even geduld, verzoek naar server...</string>
|
||||
<string name="login_no_caldav_carddav">Kon geen CalDAV of CardDAV service vinden.</string>
|
||||
<!--AccountSettingsActivity-->
|
||||
<string name="settings_title">Instellingen: %s</string>
|
||||
<string name="settings_authentication">Authenticatie</string>
|
||||
<string name="settings_username">Gebruikersnaam</string>
|
||||
<string name="settings_enter_username">Gebruikersnaam invoeren:</string>
|
||||
<string name="settings_password">Wachtwoord</string>
|
||||
<string name="settings_password_summary">Gebruik het zelfde wachtwoord als op de server.</string>
|
||||
<string name="settings_enter_password">Wachtwoord invoeren:</string>
|
||||
<string name="settings_sync">Synchronisatie</string>
|
||||
<string name="settings_sync_interval_contacts">Contacten verversen</string>
|
||||
<string name="settings_sync_summary_manually">Alleen handmatig</string>
|
||||
<string name="settings_sync_summary_periodically" tools:ignore="PluralsCandidate">Elke %d minuten + meteen na wijziging</string>
|
||||
<string name="settings_sync_interval_calendars">Agenda\'s verversen</string>
|
||||
<string name="settings_sync_interval_tasks">Taak sync. tussentijd</string>
|
||||
<string name="settings_sync_wifi_only">Sync alleen tijdens WiFi</string>
|
||||
<string name="settings_sync_wifi_only_on">Synchronisatie is voorbehouden tijdens WiFi verbindingen</string>
|
||||
<string name="settings_sync_wifi_only_off">Verbinding type is niet overwogen</string>
|
||||
<string name="settings_sync_wifi_only_ssids">WiFi SSID beperking</string>
|
||||
<string name="settings_sync_wifi_only_ssids_on">Zal alleen synchroniseren over %s</string>
|
||||
<string name="settings_sync_wifi_only_ssids_off">Alle WiFI verbindingen zullen worden gebruikt</string>
|
||||
<string name="settings_carddav">CardDAV</string>
|
||||
<string name="settings_contact_group_method">Contact groep methode</string>
|
||||
<string-array name="settings_contact_group_method_values">
|
||||
<item>GROUP_VCARDS</item>
|
||||
<item>CATEGORIES</item>
|
||||
</string-array>
|
||||
<string-array name="settings_contact_group_method_entries">
|
||||
<item>Groepen zijn apparte VCards</item>
|
||||
<item>Groepen zijn per-contact categories</item>
|
||||
</string-array>
|
||||
<string name="settings_caldav">CalDAV</string>
|
||||
<string name="settings_sync_time_range_past">Tijdslimiet verleden afspraken</string>
|
||||
<string name="settings_sync_time_range_past_none">Alle afspraken worden gesynchronizeerd</string>
|
||||
<plurals name="settings_sync_time_range_past_days">
|
||||
<item quantity="one">Afspraken ouder dan een dag worden genegeerd</item>
|
||||
<item quantity="other">Afspraken ouder dan %d dagen worden genegeerd</item>
|
||||
</plurals>
|
||||
<string name="settings_sync_time_range_past_message">Afspraken ouder dan dit aantal dagen worden genegeerd (mag 0 zijn). Laat leeg om alle afspraken te synchronizeren.</string>
|
||||
<string name="settings_manage_calendar_colors">Agenda kleuren beheren</string>
|
||||
<string name="settings_manage_calendar_colors_on">Agenda kleuren worden door DAVdroid beheerd.</string>
|
||||
<string name="settings_manage_calendar_colors_off">Agenda kleuren worden niet door DAVdroid ingesteld</string>
|
||||
<string name="settings_event_colors">Evenement kleur ondersteuning</string>
|
||||
<string name="settings_event_colors_on">Evenement kleuren synchroniseren</string>
|
||||
<string name="settings_event_colors_off">Evenement kleuren niet synchroniseren</string>
|
||||
<!--collection management-->
|
||||
<string name="create_addressbook">Maak adresboek</string>
|
||||
<string name="create_addressbook_display_name_hint">Mijn adresboek</string>
|
||||
<string name="create_calendar">Maak CalDAV collectie</string>
|
||||
<string name="create_calendar_display_name_hint">Mijn agenda</string>
|
||||
<string name="create_calendar_time_zone">Tijdzone:</string>
|
||||
<string name="create_calendar_type">Collectie type:</string>
|
||||
<string name="create_calendar_type_only_events">Agenda (alleen afspraken)</string>
|
||||
<string name="create_calendar_type_only_tasks">Takenlijst (alleen taken)</string>
|
||||
<string name="create_calendar_type_events_and_tasks">Gecombineerd (afspraken en taken)</string>
|
||||
<string name="create_collection_color">Stel een collectie kleur in</string>
|
||||
<string name="create_collection_creating">Collectie aan het maken</string>
|
||||
<string name="create_collection_display_name">Weergave naam (titel) van deze collectie:</string>
|
||||
<string name="create_collection_display_name_required">Titel is vereist</string>
|
||||
<string name="create_collection_description">Beschrijving (optioneel):</string>
|
||||
<string name="create_collection_home_set">Begin map:</string>
|
||||
<string name="create_collection_create">Maak</string>
|
||||
<string name="delete_collection">Verwijder collectie</string>
|
||||
<string name="delete_collection_confirm_title">Weet je het zeker?</string>
|
||||
<string name="delete_collection_confirm_warning">Deze collectie (%s) en alle data zal verwijderd worden van de server.</string>
|
||||
<string name="delete_collection_deleting_collection">Collectie aan het verwijderen</string>
|
||||
<!--ExceptionInfoFragment-->
|
||||
<string name="exception">Er is een fout opgetreden.</string>
|
||||
<string name="exception_httpexception">Er is een HTTP fout opgetreden.</string>
|
||||
<string name="exception_ioexception">Er is een I/O fout opgetreden.</string>
|
||||
<string name="exception_show_details">Toon details</string>
|
||||
<!--sync adapters and DebugInfoActivity-->
|
||||
<string name="debug_info_title">Debug informatie</string>
|
||||
<string name="sync_contacts_read_only_address_book">Alleen-lezen adresboek</string>
|
||||
<string name="sync_error_permissions">DAVdroid rechten</string>
|
||||
<string name="sync_error_permissions_text">Aanvullende rechten vereist</string>
|
||||
<!--cert4android-->
|
||||
<string name="certificate_notification_connection_security">DAVdroid: Verbinding beveiliging</string>
|
||||
<string name="trust_certificate_unknown_certificate_found">Davdroid is benaderd door een onbekend certificaat. Vertrouwd u dit?</string>
|
||||
</resources>
|
||||
@@ -1,225 +0,0 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!--common strings-->
|
||||
<string name="app_name">DAVdroid</string>
|
||||
<string name="account_title_address_book">Książka adresowa DAVdroid</string>
|
||||
<string name="address_books_authority_title">Książka adresowa</string>
|
||||
<string name="help">Pomoc</string>
|
||||
<string name="manage_accounts">Zadządzaj kontami</string>
|
||||
<string name="please_wait">Proszę czekać</string>
|
||||
<string name="send">Wyślij</string>
|
||||
<string name="notification_channel_debugging">Debugowanie</string>
|
||||
<!--startup dialogs-->
|
||||
<string name="startup_battery_optimization_disable">Wyłącz dla DAVdroid</string>
|
||||
<string name="startup_dont_show_again">Nie pokazuj ponownie</string>
|
||||
<string name="startup_donate">Informacje Open-Source</string>
|
||||
<string name="startup_donate_message">Jesteśmy szczęśliwi, że używasz DAVdroid, który jest oprogramowaniem open-source (GPLv3). Ponieważ rozwijanie DAVdroid jest ciężką pracą i zajęło nam tysiące godzin pracy, prosimy o rozważenie darowizny.</string>
|
||||
<string name="startup_donate_now">Pokaż stronę darowizny</string>
|
||||
<string name="startup_donate_later">Może później</string>
|
||||
<string name="startup_google_play_accounts_removed">Informacje o błędzie DRM Sklepu Play</string>
|
||||
<string name="startup_google_play_accounts_removed_message">Pod pewnymi warunkami, DRM Sklepu Play może powodować, że wszystkie konta DAVdroid mogą zostać usunięte po uruchomieniu lub po uaktualnieniu DAVdroid. Jeśli jesteś dotknięty tym problemem (i tylko wtedy) należy zainstalować \"DAVdroid JB Obejście\" ze Sklepu Play.</string>
|
||||
<string name="startup_opentasks_not_installed">OpenTasks nie jest zainstalowany</string>
|
||||
<string name="startup_opentasks_reinstall_davdroid">Po zainstalowaniu OpenTasks konieczne jest PRZEINSTALOWANIE DAVdroid i ponowne dodanie twoich kont (błąd Androida).</string>
|
||||
<string name="startup_opentasks_not_installed_install">Zainstaluj OpenTasks</string>
|
||||
<!--AboutActivity-->
|
||||
<string name="about_license_info_no_warranty">Ten program jest ABSOLUTNIE BEZ GWARANCJI. To jest wolne oprogramowanie i mile widziane jest dalsze rozpowszechnianie go pod pewnymi warunkami.</string>
|
||||
<!--global settings-->
|
||||
<string name="logging_davdroid_file_logging">Plik logów DAVdroid</string>
|
||||
<string name="logging_to_external_storage">Logowanie do zewnątrznej pamięci: %s</string>
|
||||
<string name="logging_couldnt_create_file">Nie można stworzyć zewnętrznego pliku logów: %s</string>
|
||||
<string name="logging_no_external_storage">Zewnętrzna pamięci nie została naleziona</string>
|
||||
<!--AccountsActivity-->
|
||||
<string name="navigation_drawer_open">Otwórz menu nawigacji</string>
|
||||
<string name="navigation_drawer_close">Zamknij menu nawigacji</string>
|
||||
<string name="navigation_drawer_subtitle">Adapter synchronizacji CalDAV/CardDAV</string>
|
||||
<string name="navigation_drawer_about">O DAVdroid / Licencja</string>
|
||||
<string name="navigation_drawer_beta_feedback">Przekaż opinię</string>
|
||||
<string name="navigation_drawer_settings">Ustawienia</string>
|
||||
<string name="navigation_drawer_news_updates">Nowości & aktualizacje</string>
|
||||
<string name="navigation_drawer_external_links">Zewnętrzne odnośniki</string>
|
||||
<string name="navigation_drawer_website">Strona WWW</string>
|
||||
<string name="navigation_drawer_manual">Ręcznie</string>
|
||||
<string name="navigation_drawer_faq">Pytania i odpowiedzi</string>
|
||||
<string name="navigation_drawer_forums">Pomoc / Forum</string>
|
||||
<string name="navigation_drawer_donate">Dotacja</string>
|
||||
<string name="account_list_empty">Witamy w DAVdroid!\n\nMożesz teraz dodać konto CalDAV/CardDAV.</string>
|
||||
<string name="accounts_global_sync_disabled">Automatyczna synchronizacja dla całego systemu jest wyłączona</string>
|
||||
<string name="accounts_global_sync_enable">Włącz</string>
|
||||
<!--DavService-->
|
||||
<string name="dav_service_refresh_failed">Wykrycie serwisu nie powiodło się</string>
|
||||
<string name="dav_service_refresh_couldnt_refresh">Nie można odświeżyć listy kolekcji</string>
|
||||
<!--AppSettingsActivity-->
|
||||
<string name="app_settings">Ustawienia</string>
|
||||
<string name="app_settings_user_interface">Interfejs użytkownika</string>
|
||||
<string name="app_settings_reset_hints">Zresetuj podpowiedzi</string>
|
||||
<string name="app_settings_reset_hints_summary">Ponownie włącz wskazówki, które zostały usunięte wcześniej</string>
|
||||
<string name="app_settings_reset_hints_success">Wszystkie wskazówki pojawią się ponownie</string>
|
||||
<string name="app_settings_connection">Łączność</string>
|
||||
<string name="app_settings_override_proxy">Nadpisz ustawienia proxy</string>
|
||||
<string name="app_settings_override_proxy_on">Użyj niestandardowych ustawień proxy </string>
|
||||
<string name="app_settings_override_proxy_off">Użyj systemowych ustawień proxy</string>
|
||||
<string name="app_settings_override_proxy_host">Nazwa hosta proxy HTTP</string>
|
||||
<string name="app_settings_override_proxy_port">Port proxy HTTP</string>
|
||||
<string name="app_settings_security">Bezpieczeństwo</string>
|
||||
<string name="app_settings_distrust_system_certs">Usuń certyfikaty systemowe</string>
|
||||
<string name="app_settings_distrust_system_certs_on">CA systemowe i użytkownika nie zostaną dodane</string>
|
||||
<string name="app_settings_distrust_system_certs_off">CA systemowe i użytkownika zostaną dodane (zalecane)</string>
|
||||
<string name="app_settings_reset_certificates">Zresetuj (nie)zaufane certyfikaty</string>
|
||||
<string name="app_settings_reset_certificates_summary">Zresetuj wszystkie niestandardowe certyfikaty.</string>
|
||||
<string name="app_settings_reset_certificates_success">Wszystkie niestandardowe certyfikaty zostały wyczyszczone</string>
|
||||
<string name="app_settings_debug">Debugowanie</string>
|
||||
<string name="app_settings_log_to_external_storage">Loguj do zewnętrznego pliku</string>
|
||||
<string name="app_settings_log_to_external_storage_on">Logowanie do zewnętrznej pamięci (jeśli jest dostępna)</string>
|
||||
<string name="app_settings_log_to_external_storage_off">Logowanie do zewnętrznego pliku jest wyłączone</string>
|
||||
<string name="app_settings_show_debug_info">Pokaż informacje do debugowania</string>
|
||||
<string name="app_settings_show_debug_info_details">Przejrzyj lub udostępnij informacje o programie i jego konfiguracji</string>
|
||||
<!--AccountActivity-->
|
||||
<string name="account_synchronize_now">Synchronizuj teraz</string>
|
||||
<string name="account_synchronizing_now">Synchronizcja w toku</string>
|
||||
<string name="account_settings">Ustawienia konta</string>
|
||||
<string name="account_rename">Zmień nazwę konta</string>
|
||||
<string name="account_rename_new_name">Niezapisane dane lokalne mogą zostać usunięte. Ponowna synchronizacja jest wymagana po zmianie nazwy. Nowa nazwa konta:</string>
|
||||
<string name="account_rename_rename">Zmień nazwę</string>
|
||||
<string name="account_delete">Usuń konto</string>
|
||||
<string name="account_delete_confirmation_title">Naprawdę chcesz usunąć konto?</string>
|
||||
<string name="account_delete_confirmation_text">Wszystkie lokalne kopie książek adresowych, kalendarzy i list zadań zostaną usunięte.</string>
|
||||
<string name="account_carddav">CardDAV</string>
|
||||
<string name="account_caldav">CalDAV</string>
|
||||
<string name="account_webcal">Webcal</string>
|
||||
<string name="account_synchronize_this_collection">Synchronizuj kolekcję</string>
|
||||
<string name="account_read_only">tylko do odczytu</string>
|
||||
<string name="account_calendar">kalendarz</string>
|
||||
<string name="account_task_list">lista zadań</string>
|
||||
<string name="account_refresh_address_book_list">Odśwież listę książek adresowych</string>
|
||||
<string name="account_create_new_address_book">Stwórz nową książkę adresową</string>
|
||||
<string name="account_refresh_calendar_list">Odśwież listę kalendarzy</string>
|
||||
<string name="account_create_new_calendar">Stwórz nowy kalendarz</string>
|
||||
<string name="account_no_webcal_handler_found">Nie znaleziono aplikacji obsługującej Webcal</string>
|
||||
<string name="account_install_icsdroid">Zainstaluj ICSdroid</string>
|
||||
<!--AddAccountActivity-->
|
||||
<string name="login_title">Dodaj konto</string>
|
||||
<string name="login_type_email">Logowanie za pomocą adresu e-mail</string>
|
||||
<string name="login_email_address">Adres e-mail</string>
|
||||
<string name="login_email_address_error">Wymagany poprawny adres e-mail</string>
|
||||
<string name="login_password">Hasło</string>
|
||||
<string name="login_password_required">Wymagane hasło</string>
|
||||
<string name="login_type_url">Logowanie za pomocą adresu URL i nazwy użytkownika</string>
|
||||
<string name="login_url_must_be_http_or_https">URL musi zaczynać się od http(s)://</string>
|
||||
<string name="login_url_must_be_https">Adres URL musi zaczynać się od https://</string>
|
||||
<string name="login_url_host_name_required">Wymagana nazwa hosta</string>
|
||||
<string name="login_user_name">Nazwa użytkownika</string>
|
||||
<string name="login_user_name_required">Wymagana nazwa użytkownika</string>
|
||||
<string name="login_base_url">Podstawowy URL</string>
|
||||
<string name="login_type_url_certificate">Logowanie za pomocą adresu URL i certyfikatu klienta</string>
|
||||
<string name="login_select_certificate">Wybierz certyfikat</string>
|
||||
<string name="login_login">Zaloguj</string>
|
||||
<string name="login_back">Wróć</string>
|
||||
<string name="login_create_account">Stwórz konto</string>
|
||||
<string name="login_account_name">Nazwa konta</string>
|
||||
<string name="login_account_name_info">Użyj swojego adresu e-mail jako nazwy konta, ponieważ Android będzie używał nazwy konta jako pola ORGANIZATOR dla wydarzeń, które stworzysz. Nie możesz posiadać dwóch kont o takiej samej nazwie.</string>
|
||||
<string name="login_account_contact_group_method">Metoda grupowania kontaktów:</string>
|
||||
<string name="login_account_name_required">Wymagana nazwa konta</string>
|
||||
<string name="login_account_not_created">Konto nie mogło zostać stworzone</string>
|
||||
<string name="login_configuration_detection">Wykrywanie konfiguracji</string>
|
||||
<string name="login_querying_server">Proszę czekać, odpytywanie serwera...</string>
|
||||
<string name="login_no_caldav_carddav">Nie można znaleźć usługi CalDAV lub CardDAV.</string>
|
||||
<!--AccountSettingsActivity-->
|
||||
<string name="settings_title">Ustawienia: %s</string>
|
||||
<string name="settings_authentication">Uwierzytelnianie</string>
|
||||
<string name="settings_username">Nazwa użytkownika</string>
|
||||
<string name="settings_enter_username">Wpisz nazwę użytkownika:</string>
|
||||
<string name="settings_password">Hasło</string>
|
||||
<string name="settings_password_summary">Zaktualizuj hasło zgodnie z serwerem.</string>
|
||||
<string name="settings_enter_password">Wpisz hasło:</string>
|
||||
<string name="settings_certificate_alias">Alias certyfikatu klienta</string>
|
||||
<string name="settings_sync">Synchronizacja</string>
|
||||
<string name="settings_sync_interval_contacts">Częstotliwość synchronizacji kontaktów</string>
|
||||
<string name="settings_sync_summary_manually">Tylko ręcznie</string>
|
||||
<string name="settings_sync_summary_periodically" tools:ignore="PluralsCandidate">Co %d minut oraz natychmiast przy zmianach lokalnych</string>
|
||||
<string name="settings_sync_interval_calendars">Częstotliwość synchronizacji kalendarzy</string>
|
||||
<string name="settings_sync_interval_tasks">Częstotliwość synchronizacji list zadań</string>
|
||||
<string-array name="settings_sync_interval_names">
|
||||
<item>Tylko ręcznie</item>
|
||||
<item>Co 15 minut</item>
|
||||
<item>Co 30 minut</item>
|
||||
<item>Co godzinę</item>
|
||||
<item>Co 2 godziny</item>
|
||||
<item>Co 4 godziny</item>
|
||||
<item>Raz dziennie</item>
|
||||
</string-array>
|
||||
<string name="settings_sync_wifi_only">Synchronizuj tylko przez WiFi</string>
|
||||
<string name="settings_sync_wifi_only_on">Synchronizacja jest ograniczona do połączeń WiFi</string>
|
||||
<string name="settings_sync_wifi_only_off">Rodzaj połączenia nie jest brany pod uwagę</string>
|
||||
<string name="settings_sync_wifi_only_ssids">Ograniczenia WiFi SSID</string>
|
||||
<string name="settings_sync_wifi_only_ssids_on">Będzie synchronizować tylko w %s</string>
|
||||
<string name="settings_sync_wifi_only_ssids_off">Wszystkie połączenia WiFi będą używane</string>
|
||||
<string name="settings_sync_wifi_only_ssids_message">Nazwy oddzielone przecinkami (SSID) dozwolonych sieci WiFi (pozostaw puste dla wszystkich)</string>
|
||||
<string name="settings_carddav">CardDAV</string>
|
||||
<string name="settings_contact_group_method">Metoda grupowania kontaktów</string>
|
||||
<string-array name="settings_contact_group_method_values">
|
||||
<item>GROUP_VCARDS</item>
|
||||
<item>CATEGORIES</item>
|
||||
</string-array>
|
||||
<string name="settings_contact_group_method_change">Zmień metodę grupową</string>
|
||||
<string name="settings_contact_group_method_change_reload_contacts">Wymaga to ponownego pobrania wszystkich kontaktów. Niezapisane zmiany z tego telefonu zostaną usunięte.</string>
|
||||
<string name="settings_caldav">CalDAV</string>
|
||||
<string name="settings_sync_time_range_past">Limit czasowy przeszłych wydarzeń</string>
|
||||
<string name="settings_sync_time_range_past_none">Wszystkie wydarzenia zostaną zsynchronizowane</string>
|
||||
<plurals name="settings_sync_time_range_past_days">
|
||||
<item quantity="one">Wydarzenia starsze niż jeden dzień zostaną zignorowane.</item>
|
||||
<item quantity="few">Wydarzenia starsze niż %d dni zostaną zignorowane.</item>
|
||||
<item quantity="many">Wydarzenia starsze niż %d dni zostaną zignorowane.</item>
|
||||
<item quantity="other">Wydarzenia starsze niż %d dni zostaną zignorowane.</item>
|
||||
</plurals>
|
||||
<string name="settings_sync_time_range_past_message">Wydarzenia, które są starsze niż podana liczba dni zostaną zignorowane (może być 0). Zostaw puste, aby synchronizować wszystkie wydarzenia.</string>
|
||||
<string name="settings_manage_calendar_colors">Zarządzaj kolorami kalendarza</string>
|
||||
<string name="settings_manage_calendar_colors_on">Kolory kalendarza są zarządzane przez DAVdroid</string>
|
||||
<string name="settings_manage_calendar_colors_off">Kolory kalendarze nie są ustawiane przez DAVdroid</string>
|
||||
<string name="settings_event_colors">Obsługa kolorów wydarzeń</string>
|
||||
<string name="settings_event_colors_on">Synchronizuj kolory zdarzeń</string>
|
||||
<string name="settings_event_colors_off">Nie synchronizuj kolorów zdarzeń</string>
|
||||
<string name="settings_event_colors_off_confirm">Wyłączenie kolorów zdarzeń może usunąć już zsynchronizowane kolory zdarzeń.</string>
|
||||
<!--collection management-->
|
||||
<string name="create_addressbook">Stwórz książkę adresową</string>
|
||||
<string name="create_addressbook_display_name_hint">Moja książka adresowa</string>
|
||||
<string name="create_calendar">Stwórz kolekcję CalDAV</string>
|
||||
<string name="create_calendar_display_name_hint">Mój kalendarz</string>
|
||||
<string name="create_calendar_time_zone">Strefa czasowa:</string>
|
||||
<string name="create_calendar_type">Typ kolekcji:</string>
|
||||
<string name="create_calendar_type_only_events">Kalendarz (tylko wydarzenia)</string>
|
||||
<string name="create_calendar_type_only_tasks">Lista zadań (tylko zadań)</string>
|
||||
<string name="create_calendar_type_events_and_tasks">Połączone (wydarzenia i zadania)</string>
|
||||
<string name="create_collection_color">Ustaw kolor kolekcji</string>
|
||||
<string name="create_collection_creating">Tworzenie kolekcji</string>
|
||||
<string name="create_collection_display_name">Nazwa wyświetlana (tytuł) kolekcji:</string>
|
||||
<string name="create_collection_display_name_required">Tytuł jest wymagany</string>
|
||||
<string name="create_collection_description">Opis (opcjonalnie)</string>
|
||||
<string name="create_collection_home_set">Ustaw początek:</string>
|
||||
<string name="create_collection_create">Stwórz</string>
|
||||
<string name="delete_collection">Usuń kolekcję</string>
|
||||
<string name="delete_collection_confirm_title">Czy jesteś pewien?</string>
|
||||
<string name="delete_collection_confirm_warning">Kolekcja (%s) i jej wszystkie dane zostaną usunięte z serwera.</string>
|
||||
<string name="delete_collection_deleting_collection">Usuwanie kolekcji</string>
|
||||
<string name="collection_force_read_only">Wymuś tylko do odczytu</string>
|
||||
<!--ExceptionInfoFragment-->
|
||||
<string name="exception">Wystąpił błąd.</string>
|
||||
<string name="exception_httpexception">Wystąpił błąd HTTP.</string>
|
||||
<string name="exception_ioexception">Wystąpił błąd I/O.</string>
|
||||
<string name="exception_show_details">Pokaż szczegóły</string>
|
||||
<!--sync adapters and DebugInfoActivity-->
|
||||
<string name="debug_info_title">Informacje debugowe</string>
|
||||
<string name="sync_contacts_read_only_address_book">Książka adresowa tylko do odczytu</string>
|
||||
<plurals name="sync_contacts_local_contact_changes_discarded">
|
||||
<item quantity="one">Lokalny kontakt zostanie odrzucony</item>
|
||||
<item quantity="few">%d lokalne kontakty zostaną odrzucone</item>
|
||||
<item quantity="many">%d lokalne kontakty zostaną odrzucone</item>
|
||||
<item quantity="other">%d lokalne kontakty zostaną odrzucone</item>
|
||||
</plurals>
|
||||
<string name="sync_error_permissions">Uprawnienia DAVdroid</string>
|
||||
<string name="sync_error_permissions_text">Wymagane dodatkowe uprawnienia</string>
|
||||
<string name="sync_error_opentasks_too_old">OpenTask jest niekompatybilny</string>
|
||||
<string name="sync_error_opentasks_required_version">Wymagana wersja: %1$s (obecna %2$s)</string>
|
||||
<!--cert4android-->
|
||||
<string name="certificate_notification_connection_security">DAVdroid: Bezpieczeństwo połączenia</string>
|
||||
<string name="trust_certificate_unknown_certificate_found">DAVdroid napotkał nieznany certyfikat. Czy chcesz go dodać?</string>
|
||||
</resources>
|
||||
@@ -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
|
||||
-->
|
||||
|
||||
<resources>
|
||||
|
||||
<style name="AppThemeExt" parent="AppTheme">
|
||||
<item name="android:windowActivityTransitions">true</item>
|
||||
<item name="android:windowEnterTransition">@android:transition/slide_right</item>
|
||||
<item name="android:windowExitTransition">@android:transition/slide_left</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
@@ -1,65 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ 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
|
||||
-->
|
||||
|
||||
<menu xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<item
|
||||
android:id="@+id/nav_about"
|
||||
android:icon="@drawable/ic_info_dark"
|
||||
android:title="@string/navigation_drawer_about"/>
|
||||
|
||||
<item
|
||||
android:id="@+id/nav_beta_feedback"
|
||||
android:icon="@drawable/ic_forum_dark"
|
||||
android:title="@string/navigation_drawer_beta_feedback"
|
||||
android:visible="false"/>
|
||||
|
||||
<item
|
||||
android:id="@+id/nav_app_settings"
|
||||
android:icon="@drawable/ic_settings_dark"
|
||||
android:title="@string/navigation_drawer_settings"/>
|
||||
|
||||
<item android:title="@string/navigation_drawer_news_updates">
|
||||
<menu>
|
||||
<item
|
||||
android:id="@+id/nav_twitter"
|
||||
android:icon="@drawable/twitter"
|
||||
android:title="\@davdroidapp"
|
||||
tools:ignore="HardcodedText"/>
|
||||
</menu>
|
||||
</item>
|
||||
|
||||
<item android:title="@string/navigation_drawer_external_links">
|
||||
<menu>
|
||||
<item
|
||||
android:id="@+id/nav_website"
|
||||
android:icon="@drawable/ic_home_dark"
|
||||
android:title="@string/navigation_drawer_website"/>
|
||||
<item
|
||||
android:id="@+id/nav_manual"
|
||||
android:icon="@drawable/ic_info_dark"
|
||||
android:title="@string/navigation_drawer_manual"/>
|
||||
<item
|
||||
android:id="@+id/nav_faq"
|
||||
android:icon="@drawable/ic_help_dark"
|
||||
android:title="@string/navigation_drawer_faq"/>
|
||||
<item
|
||||
android:id="@+id/nav_forums"
|
||||
android:icon="@drawable/ic_forum_dark"
|
||||
android:title="@string/navigation_drawer_forums"/>
|
||||
<item
|
||||
android:id="@+id/nav_donate"
|
||||
android:icon="@drawable/ic_attach_money_dark"
|
||||
android:title="@string/navigation_drawer_donate"
|
||||
android:visible="false"/>
|
||||
</menu>
|
||||
</item>
|
||||
|
||||
</menu>
|
||||
@@ -1,11 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
~ All rights reserved. This program and the accompanying materials
|
||||
~ are made available under the terms of the GNU Public License v3.0
|
||||
~ which accompanies this distribution, and is available at
|
||||
~ http://www.gnu.org/licenses/gpl.html
|
||||
-->
|
||||
<manifest package="at.bitfire.davdroid"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
@@ -25,13 +18,6 @@
|
||||
<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"/>
|
||||
@@ -41,24 +27,27 @@
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR"/>
|
||||
|
||||
<!-- android.permission-group.LOCATION -->
|
||||
<!-- required since Android 8.1 to get the WiFi name (for "sync in Wifi only" feature) -->
|
||||
<uses-permission-sdk-23 android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
<!-- 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_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="true"
|
||||
android:fullBackupContent="false"
|
||||
android:allowBackup="false"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppThemeExt"
|
||||
android:theme="@style/AppTheme"
|
||||
tools:ignore="UnusedAttribute">
|
||||
|
||||
<service android:name=".DavService"/>
|
||||
<service android:name=".settings.Settings"/>
|
||||
|
||||
<activity android:name=".ui.intro.IntroActivity" android:theme="@style/AppTheme.NoActionBar" />
|
||||
<activity
|
||||
android:name=".ui.AccountsActivity"
|
||||
android:label="@string/app_name"
|
||||
@@ -78,7 +67,13 @@
|
||||
<activity
|
||||
android:name=".ui.AppSettingsActivity"
|
||||
android:label="@string/app_settings"
|
||||
android:parentActivityName=".ui.AccountsActivity"/>
|
||||
android:parentActivityName=".ui.AccountsActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.APPLICATION_PREFERENCES"/>
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.setup.LoginActivity"
|
||||
@@ -90,10 +85,10 @@
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.AccountActivity"
|
||||
android:parentActivityName=".ui.AccountsActivity">
|
||||
</activity>
|
||||
<activity android:name=".ui.AccountSettingsActivity"/>
|
||||
android:name=".ui.account.AccountActivity"
|
||||
android:parentActivityName=".ui.AccountsActivity"
|
||||
android:theme="@style/AppTheme.NoActionBar"/>
|
||||
<activity android:name=".ui.account.SettingsActivity"/>
|
||||
<activity android:name=".ui.CreateAddressBookActivity"
|
||||
android:label="@string/create_addressbook"/>
|
||||
<activity android:name=".ui.CreateCalendarActivity"
|
||||
@@ -104,18 +99,27 @@
|
||||
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>
|
||||
<provider
|
||||
android:name="android.support.v4.content.FileProvider"
|
||||
android:authorities="@string/authority_log_provider"
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="@string/authority_debug_provider"
|
||||
android:grantUriPermissions="true"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/log_paths" />
|
||||
android:resource="@xml/debug_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- account type "DAVdroid" -->
|
||||
<activity
|
||||
android:name=".ui.PermissionsActivity"
|
||||
android:label="@string/app_settings_security_app_permissions"
|
||||
android:parentActivityName=".ui.AppSettingsActivity" />
|
||||
|
||||
<!-- account type "DAVx⁵" -->
|
||||
<service
|
||||
android:name=".syncadapter.AccountAuthenticatorService"
|
||||
android:exported="false">
|
||||
@@ -130,7 +134,6 @@
|
||||
<service
|
||||
android:name=".syncadapter.CalendarsSyncAdapterService"
|
||||
android:exported="true"
|
||||
android:process=":sync"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
@@ -143,7 +146,6 @@
|
||||
<service
|
||||
android:name=".syncadapter.TasksSyncAdapterService"
|
||||
android:exported="true"
|
||||
android:process=":sync"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
@@ -154,7 +156,7 @@
|
||||
android:resource="@xml/sync_tasks"/>
|
||||
</service>
|
||||
|
||||
<!-- account type "DAVdroid Address book" -->
|
||||
<!-- account type "DAVx⁵ Address book" -->
|
||||
<service
|
||||
android:name=".syncadapter.NullAuthenticatorService"
|
||||
android:exported="false">
|
||||
@@ -175,7 +177,6 @@
|
||||
<service
|
||||
android:name=".syncadapter.AddressBooksSyncAdapterService"
|
||||
android:exported="true"
|
||||
android:process=":sync"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
@@ -188,7 +189,6 @@
|
||||
<service
|
||||
android:name=".syncadapter.ContactsSyncAdapterService"
|
||||
android:exported="true"
|
||||
android:process=":sync"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.SyncAdapter"/>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package at.bitfire.davdroid.settings;
|
||||
|
||||
import at.bitfire.davdroid.settings.ISettingsObserver;
|
||||
|
||||
interface ISettings {
|
||||
|
||||
void forceReload();
|
||||
|
||||
boolean has(String key);
|
||||
|
||||
boolean getBoolean(String key, boolean defaultValue);
|
||||
int getInt(String key, int defaultValue);
|
||||
long getLong(String key, long defaultValue);
|
||||
String getString(String key, String defaultValue);
|
||||
|
||||
boolean isWritable(String key);
|
||||
|
||||
boolean putBoolean(String key, boolean value);
|
||||
boolean putInt(String key, int value);
|
||||
boolean putLong(String key, long value);
|
||||
boolean putString(String key, String value);
|
||||
|
||||
boolean remove(String key);
|
||||
|
||||
void registerObserver(ISettingsObserver observer);
|
||||
void unregisterObserver(ISettingsObserver observer);
|
||||
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package at.bitfire.davdroid.settings;
|
||||
|
||||
interface ISettingsObserver {
|
||||
|
||||
void onSettingsChanged();
|
||||
|
||||
}
|
||||
@@ -1,78 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ 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
|
||||
-->
|
||||
|
||||
<resources>
|
||||
|
||||
<string name="define_ambilwarna" translatable="false" />
|
||||
<string name="library_ambilwarna_author" translatable="false">Randy Sugianto</string>
|
||||
<string name="library_ambilwarna_authorWebsite">https://github.com/yukuku</string>
|
||||
<string name="library_ambilwarna_libraryName" translatable="false">AmbilWarna</string>
|
||||
<string name="library_ambilwarna_libraryDescription">This is a small library for your application to enable the users to select an arbitrary color.</string>
|
||||
<string name="library_ambilwarna_libraryWebsite" translatable="false">https://github.com/yukuku/ambilwarna</string>
|
||||
<string name="library_ambilwarna_isOpenSource" translatable="false">true</string>
|
||||
<string name="library_ambilwarna_licenseId">apache_2_0</string>
|
||||
|
||||
<string name="define_commons" translatable="false" />
|
||||
<string name="library_commons_author" translatable="false">Apache Software Foundation</string>
|
||||
<string name="library_commons_authorWebsite">https://www.apache.org/</string>
|
||||
<string name="library_commons_libraryName" translatable="false">Apache Commons</string>
|
||||
<string name="library_commons_libraryDescription">Apache Commons is an Apache project focused on all aspects of reusable Java components.</string>
|
||||
<string name="library_commons_libraryWebsite" translatable="false">https://commons.apache.org/components.html</string>
|
||||
<string name="library_commons_isOpenSource" translatable="false">true</string>
|
||||
<string name="library_commons_licenseId">apache_2_0</string>
|
||||
|
||||
<string name="define_dnsjava" translatable="false" />
|
||||
<string name="library_dnsjava_author" translatable="false">Brian Wellington</string>
|
||||
<string name="library_dnsjava_authorWebsite">http://www.xbill.org/~bwelling/</string>
|
||||
<string name="library_dnsjava_libraryName" translatable="false">dnsjava</string>
|
||||
<string name="library_dnsjava_libraryDescription">dnsjava is an implementation of DNS in Java.</string>
|
||||
<string name="library_dnsjava_libraryWebsite" translatable="false">http://dnsjava.org/</string>
|
||||
<string name="library_dnsjava_isOpenSource" translatable="false">true</string>
|
||||
<string name="library_dnsjava_licenseId">bsd_3</string>
|
||||
|
||||
<string name="define_ezvcard" translatable="false" />
|
||||
<string name="library_ezvcard_author" translatable="false">Michael Angstadt</string>
|
||||
<string name="library_ezvcard_authorWebsite" translatable="false">http://mikeangstadt.name/</string>
|
||||
<string name="library_ezvcard_libraryName" translatable="false">ez-vcard</string>
|
||||
<string name="library_ezvcard_libraryDescription">ez-vcard is a vCard library written in Java.</string>
|
||||
<string name="library_ezvcard_libraryWebsite" translatable="false">https://github.com/mangstadt/ez-vcard</string>
|
||||
<string name="library_ezvcard_isOpenSource" translatable="false">true</string>
|
||||
<string name="library_ezvcard_licenseId">bsd_2</string>
|
||||
|
||||
<string name="define_ical4j" translatable="false" />
|
||||
<string name="library_ical4j_author" translatable="false">Ben Fortuna</string>
|
||||
<string name="library_ical4j_authorWebsite">http://basepatterns.org/</string>
|
||||
<string name="library_ical4j_libraryName" translatable="false">ical4j</string>
|
||||
<string name="library_ical4j_libraryDescription">iCal4j is a Java API that provides support for the iCalendar specification as defined in RFC2445.</string>
|
||||
<string name="library_ical4j_libraryWebsite" translatable="false">https://ical4j.github.io/</string>
|
||||
<string name="library_ical4j_isOpenSource" translatable="false">true</string>
|
||||
<string name="library_ical4j_licenseId">bsd_3</string>
|
||||
|
||||
<string name="define_okhttp" translatable="false" />
|
||||
<string name="library_okhttp_author" translatable="false">Square, Inc.</string>
|
||||
<string name="library_okhttp_authorWebsite">https://squareup.com/</string>
|
||||
<string name="library_okhttp_libraryName" translatable="false">okhttp</string>
|
||||
<string name="library_okhttp_libraryDescription">An HTTP+HTTP/2 client for Android and Java applications that\’s efficient by default.</string>
|
||||
<string name="library_okhttp_libraryWebsite" translatable="false">http://square.github.io/okhttp/</string>
|
||||
<string name="library_okhttp_isOpenSource" translatable="false">true</string>
|
||||
<string name="library_okhttp_licenseId">apache_2_0</string>
|
||||
|
||||
|
||||
<!-- license texts -->
|
||||
<string name="gpl_v3"><![CDATA[
|
||||
<h3 style="text-align: center;">GNU GENERAL PUBLIC LICENSE</h3>
|
||||
<h3 style="text-align: center;">GNU GENERAL PUBLIC LICENSE</h3>
|
||||
<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>
|
||||
|
||||
@@ -694,6 +626,3 @@ Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.</p>
|
||||
|
||||
<p>END OF TERMS AND CONDITIONS</p>
|
||||
]]></string>
|
||||
|
||||
</resources>
|
||||
1
app/src/main/assets/translators.json
Normal file
@@ -0,0 +1 @@
|
||||
{"ar_SA":["abdunnasir"],"bg":["dpa_transifex"],"ca":["jordibrus","Kintu","zagur"],"cs":["pavelb","tomas.odehnal"],"da":["knutztar","mjjzf","Tntdruid_","twikedk"],"de":["anestiskaci","Atalanttore","corppneq","crit12","maxkl","nicolas_git","owncube","TheName","Wyrrrd","YvanM"],"el":["anestiskaci","diamond_gr","KristinaQejvanaj"],"es":["aluaces","Ark74","Elhea","GranPC","jcvielma","plaguna","polkhas","xphnx"],"eu":["cockeredradiation","Osoitz","Thadah"],"fa":["ahangarha","amiraliakbari","joojoojoo","maryambehzi","mtashackori","Numb","taranehsaei"],"fi_FI":["raketti","tseipii"],"fr":["AlainR","alkino2","Amadeen","boutil","callmemagnus","chfo","chrcha","Floflr","grenatrad","jokx","Jorg722","Llorc","LoiX07","mathieugfortin","Novick","Poussinou","Thecross","vincen","YvanM","ÉricB."],"fr_FR":["chrcha","Llorc","Poussinou"],"gl":["aluaces","pikamoku"],"hu":["jtg","Roshek"],"it":["Damtux","ed0","FranzMari","noccio","nwandy","rickyroo","technezio"],"ja":["Naofumi"],"nb_NO":["elonus"],"nl":["davtemp","dehart","erikhubers","frankyboy1963","toonvangerwen","XtremeNova"],"pl":["gsz","mg6","oskarjakiela","TheName","TORminator"],"pt_BR":["amalvarenga","wanderlei.huttel"],"ru":["aigoshin","anm","astalavister","nick.savin","vaddd"],"sk_SK":["brango67","tiborepcek"],"sl_SI":["MrLaaky","uroszor"],"sr":["daimonion"],"szl":["chlodny"],"tr_TR":["ooguz","pultars"],"uk":["androsua","olexn","twixi007"],"uk_UA":["astalavister"],"zh_CN":["anolir","jxj2zzz79pfp9bpo","linuxbckp","mofitt2016","oksjd","phy","spice2wolf"],"zh_TW":["linuxbckp","mofitt2016","phy","waiabsfabuloushk"]}
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 23 KiB |
29
app/src/main/java/at/bitfire/davdroid/AndroidSingleton.kt
Normal file
@@ -0,0 +1,29 @@
|
||||
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
|
||||
}
|
||||
|
||||
abstract fun createInstance(context: Context): T
|
||||
|
||||
}
|
||||
@@ -11,56 +11,26 @@ package at.bitfire.davdroid
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.StrictMode
|
||||
import android.support.v7.app.AppCompatDelegate
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import kotlin.concurrent.thread
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.logging.Level
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class App: Application() {
|
||||
@Suppress("unused")
|
||||
class App: Application(), Thread.UncaughtExceptionHandler {
|
||||
|
||||
companion object {
|
||||
|
||||
const val FLAVOR_GOOGLE_PLAY = "gplay"
|
||||
const val FLAVOR_ICLOUD = "icloud"
|
||||
const val FLAVOR_MANAGED = "managed"
|
||||
const val FLAVOR_SOLDUPE = "soldupe"
|
||||
const val FLAVOR_STANDARD = "standard"
|
||||
|
||||
const val ORGANIZATION = "organization"
|
||||
const val ORGANIZATION_LOGO_URL = "logo_url"
|
||||
|
||||
const val SUPPORT_HOMEPAGE = "support_homepage_url"
|
||||
const val SUPPORT_PHONE = "support_phone_number"
|
||||
const val SUPPORT_EMAIL = "support_email_address"
|
||||
|
||||
const val MAX_ACCOUNTS = "max_accounts"
|
||||
|
||||
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"
|
||||
|
||||
const val OVERRIDE_PROXY_HOST_DEFAULT = "localhost"
|
||||
const val OVERRIDE_PROXY_PORT_DEFAULT = 8118
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
fun getLauncherBitmap(context: Context) =
|
||||
AppCompatResources.getDrawable(context, R.mipmap.ic_launcher)?.toBitmap()
|
||||
|
||||
fun homepageUrl(context: Context) =
|
||||
Uri.parse(context.getString(R.string.homepage_url)).buildUpon()
|
||||
@@ -76,7 +46,8 @@ class App: Application() {
|
||||
super.onCreate()
|
||||
Logger.initialize(this)
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
if (BuildConfig.DEBUG)
|
||||
// debug builds
|
||||
StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder()
|
||||
.detectActivityLeaks()
|
||||
.detectFileUriExposure()
|
||||
@@ -85,31 +56,31 @@ class App: Application() {
|
||||
.detectLeakedSqlLiteObjects()
|
||||
.penaltyLog()
|
||||
.build())
|
||||
|
||||
// main thread
|
||||
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build())
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT <= 21)
|
||||
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
|
||||
else // if (BuildConfig.FLAVOR == FLAVOR_STANDARD)
|
||||
// handle uncaught exceptions in non-debug standard flavor
|
||||
Thread.setDefaultUncaughtExceptionHandler(this)
|
||||
|
||||
NotificationUtils.createChannels(this)
|
||||
|
||||
// don't block UI for some background checks
|
||||
thread {
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
// watch installed/removed apps
|
||||
val tasksFilter = IntentFilter()
|
||||
tasksFilter.addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
tasksFilter.addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED)
|
||||
tasksFilter.addDataScheme("package")
|
||||
registerReceiver(PackageChangedReceiver(), tasksFilter)
|
||||
OpenTasksWatcher(this@App)
|
||||
|
||||
// check whether a tasks app is currently installed
|
||||
PackageChangedReceiver.updateTaskSync(this)
|
||||
OpenTasksWatcher.updateTaskSync(this@App)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
override fun uncaughtException(t: Thread, e: Throwable) {
|
||||
Logger.log.log(Level.SEVERE, "Unhandled exception!", e)
|
||||
|
||||
val intent = Intent(this, DebugInfoActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
intent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
|
||||
startActivity(intent)
|
||||
|
||||
exitProcess(1)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
12
app/src/main/java/at/bitfire/davdroid/CompatUtils.kt
Normal file
@@ -0,0 +1,12 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,165 +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.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.*
|
||||
|
||||
/**
|
||||
* Custom TLS socket factory with support for
|
||||
* - enabling/disabling algorithms depending on the Android version,
|
||||
* - client certificate authentication
|
||||
*/
|
||||
class CustomTlsSocketFactory(
|
||||
keyManager: KeyManager?,
|
||||
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 {
|
||||
(SSLSocketFactory.getDefault().createSocket() as? SSLSocket)?.use { socket ->
|
||||
try {
|
||||
/* 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(
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
init {
|
||||
try {
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(
|
||||
if (keyManager != null) arrayOf(keyManager) else 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 }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,38 +10,34 @@ package at.bitfire.davdroid
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.Intent
|
||||
import android.database.DatabaseUtils
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.os.Binder
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import android.support.v4.app.NotificationManagerCompat
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.Response
|
||||
import at.bitfire.dav4android.UrlUtils
|
||||
import at.bitfire.dav4android.exception.DavException
|
||||
import at.bitfire.dav4android.exception.HttpException
|
||||
import at.bitfire.dav4android.property.*
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.room.Transaction
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.*
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB.*
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.model.*
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.IOException
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class DavService: Service() {
|
||||
class DavService: android.app.Service() {
|
||||
|
||||
companion object {
|
||||
const val ACTION_REFRESH_COLLECTIONS = "refreshCollections"
|
||||
@@ -52,6 +48,16 @@ class DavService: Service() {
|
||||
contents://<authority>/<account.type>/<account name>
|
||||
**/
|
||||
const val ACTION_FORCE_SYNC = "forceSync"
|
||||
|
||||
val DAV_COLLECTION_PROPERTIES = arrayOf(
|
||||
ResourceType.NAME,
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
DisplayName.NAME,
|
||||
AddressbookDescription.NAME, SupportedAddressData.NAME,
|
||||
CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME,
|
||||
Source.NAME
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
private val runningRefresh = HashSet<Long>()
|
||||
@@ -65,15 +71,20 @@ class DavService: Service() {
|
||||
when (intent.action) {
|
||||
ACTION_REFRESH_COLLECTIONS ->
|
||||
if (runningRefresh.add(id)) {
|
||||
thread { refreshCollections(id) }
|
||||
refreshingStatusListeners.forEach { it.get()?.onDavRefreshStatusChanged(id, true) }
|
||||
refreshingStatusListeners.forEach { listener ->
|
||||
listener.get()?.onDavRefreshStatusChanged(id, true)
|
||||
}
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
refreshCollections(id)
|
||||
}
|
||||
}
|
||||
|
||||
ACTION_FORCE_SYNC -> {
|
||||
val authority = intent.data.authority
|
||||
val uri = intent.data!!
|
||||
val authority = uri.authority!!
|
||||
val account = Account(
|
||||
intent.data.pathSegments[1],
|
||||
intent.data.pathSegments[0]
|
||||
uri.pathSegments[1],
|
||||
uri.pathSegments[0]
|
||||
)
|
||||
forceSync(authority, account)
|
||||
}
|
||||
@@ -97,9 +108,9 @@ class DavService: Service() {
|
||||
inner class InfoBinder: Binder() {
|
||||
fun isRefreshing(id: Long) = runningRefresh.contains(id)
|
||||
|
||||
fun addRefreshingStatusListener(listener: RefreshingStatusListener, callImmediate: Boolean) {
|
||||
fun addRefreshingStatusListener(listener: RefreshingStatusListener, callImmediateIfRunning: Boolean) {
|
||||
refreshingStatusListeners += WeakReference<RefreshingStatusListener>(listener)
|
||||
if (callImmediate)
|
||||
if (callImmediateIfRunning)
|
||||
runningRefresh.forEach { id -> listener.onDavRefreshStatusChanged(id, true) }
|
||||
}
|
||||
|
||||
@@ -129,257 +140,237 @@ class DavService: Service() {
|
||||
ContentResolver.requestSync(account, authority, extras)
|
||||
}
|
||||
|
||||
private fun refreshCollections(service: Long) {
|
||||
OpenHelper(this@DavService).use { dbHelper ->
|
||||
val db = dbHelper.writableDatabase
|
||||
private fun refreshCollections(serviceId: Long) {
|
||||
val db = AppDatabase.getInstance(this)
|
||||
val homeSetDao = db.homeSetDao()
|
||||
val collectionDao = db.collectionDao()
|
||||
|
||||
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 service = db.serviceDao().get(serviceId) ?: throw IllegalArgumentException("Service not found")
|
||||
val account = Account(service.accountName, getString(R.string.account_type))
|
||||
|
||||
val account by lazy {
|
||||
db.query(Services._TABLE, arrayOf(Services.ACCOUNT_NAME), "${Services.ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return@lazy Account(cursor.getString(0), getString(R.string.account_type))
|
||||
}
|
||||
throw IllegalArgumentException("Account not found")
|
||||
}
|
||||
val homeSets = homeSetDao.getByService(serviceId).associateBy { it.url }.toMutableMap()
|
||||
val collections = collectionDao.getByService(serviceId).associateBy { it.url }.toMutableMap()
|
||||
|
||||
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
|
||||
}
|
||||
/**
|
||||
* Checks if the given URL defines home sets and adds them to the home set list.
|
||||
*
|
||||
* @throws java.io.IOException
|
||||
* @throws HttpException
|
||||
* @throws at.bitfire.dav4jvm.exception.DavException
|
||||
*/
|
||||
fun queryHomeSets(client: OkHttpClient, url: HttpUrl, recurse: Boolean = true) {
|
||||
val related = mutableSetOf<HttpUrl>()
|
||||
|
||||
val 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)) }
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
collections
|
||||
}
|
||||
|
||||
fun readPrincipal(): HttpUrl? {
|
||||
db.query(Services._TABLE, arrayOf(Services.PRINCIPAL), "${Services.ID}=?", arrayOf(service.toString()), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
cursor.getString(0)?.let { return HttpUrl.parse(it) }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given URL defines home sets and adds them to the home set list.
|
||||
*/
|
||||
@Throws(IOException::class, HttpException::class, DavException::class)
|
||||
fun queryHomeSets(client: OkHttpClient, url: HttpUrl, recurse: Boolean = true) {
|
||||
var related = setOf<HttpUrl>()
|
||||
|
||||
fun findRelated(root: HttpUrl, dav: Response) {
|
||||
// refresh home sets: calendar-proxy-read/write-for
|
||||
dav[CalendarProxyReadFor::class.java]?.let {
|
||||
for (href in it.hrefs) {
|
||||
Logger.log.fine("Principal is a read-only proxy for $href, checking for home sets")
|
||||
root.resolve(href)?.let {
|
||||
related += it
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
related += it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
related += it
|
||||
}
|
||||
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 dav = DavResource(client, url)
|
||||
when (serviceType) {
|
||||
Services.SERVICE_CARDDAV ->
|
||||
dav.propfind(0, AddressbookHomeSet.NAME, GroupMembership.NAME) { response, _ ->
|
||||
response[AddressbookHomeSet::class.java]?.let {
|
||||
for (href in it.hrefs)
|
||||
dav.location.resolve(href)?.let { homeSets += UrlUtils.withTrailingSlash(it) }
|
||||
}
|
||||
|
||||
if (recurse)
|
||||
findRelated(dav.location, response)
|
||||
}
|
||||
Services.SERVICE_CALDAV -> {
|
||||
dav.propfind(0, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME) { response, _ ->
|
||||
response[CalendarHomeSet::class.java]?.let {
|
||||
for (href in it.hrefs)
|
||||
dav.location.resolve(href)?.let { homeSets.add(UrlUtils.withTrailingSlash(it)) }
|
||||
}
|
||||
|
||||
if (recurse)
|
||||
findRelated(dav.location, response)
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (resource in related)
|
||||
queryHomeSets(client, resource, false)
|
||||
}
|
||||
|
||||
fun saveHomeSets() {
|
||||
db.delete(HomeSets._TABLE, "${HomeSets.SERVICE_ID}=?", arrayOf(service.toString()))
|
||||
for (homeSet in homeSets) {
|
||||
val values = ContentValues(2)
|
||||
values.put(HomeSets.SERVICE_ID, service)
|
||||
values.put(HomeSets.URL, homeSet.toString())
|
||||
db.insertOrThrow(HomeSets._TABLE, null, values)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveCollections() {
|
||||
db.delete(Collections._TABLE, "${HomeSets.SERVICE_ID}=?", arrayOf(service.toString()))
|
||||
for ((_,collection) in collections) {
|
||||
val values = collection.toDB()
|
||||
Logger.log.log(Level.FINE, "Saving collection", values)
|
||||
values.put(Collections.SERVICE_ID, service)
|
||||
db.insertWithOnConflict(Collections._TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
Logger.log.info("Refreshing $serviceType collections of service #$service")
|
||||
|
||||
Settings.getInstance(this)?.use { settings ->
|
||||
// create authenticating OkHttpClient (credentials taken from account settings)
|
||||
HttpClient.Builder(this, settings, AccountSettings(this, settings, account))
|
||||
.setForeground(true)
|
||||
.build().use { client ->
|
||||
val httpClient = client.okHttpClient
|
||||
|
||||
// refresh home set list (from principal)
|
||||
readPrincipal()?.let { principalUrl ->
|
||||
Logger.log.fine("Querying principal $principalUrl for home sets")
|
||||
queryHomeSets(httpClient, principalUrl)
|
||||
}
|
||||
|
||||
// remember selected collections
|
||||
val selectedCollections = HashSet<HttpUrl>()
|
||||
collections.values
|
||||
.filter { it.selected }
|
||||
.forEach { (url, _) -> selectedCollections.add(url) }
|
||||
|
||||
// now refresh collections (taken from home sets)
|
||||
val itHomeSets = homeSets.iterator()
|
||||
while (itHomeSets.hasNext()) {
|
||||
val homeSetUrl = itHomeSets.next()
|
||||
Logger.log.fine("Listing home set $homeSetUrl")
|
||||
|
||||
try {
|
||||
DavResource(httpClient, homeSetUrl).propfind(1, *CollectionInfo.DAV_PROPERTIES) { response, _ ->
|
||||
if (!response.isSuccess())
|
||||
return@propfind
|
||||
|
||||
val info = CollectionInfo(response)
|
||||
info.confirmed = true
|
||||
Logger.log.log(Level.FINE, "Found collection", info)
|
||||
|
||||
if ((serviceType == Services.SERVICE_CARDDAV && info.type == CollectionInfo.Type.ADDRESS_BOOK) ||
|
||||
(serviceType == Services.SERVICE_CALDAV && arrayOf(CollectionInfo.Type.CALENDAR, CollectionInfo.Type.WEBCAL).contains(info.type)))
|
||||
collections[response.href] = info
|
||||
}
|
||||
} catch(e: HttpException) {
|
||||
if (e.code in arrayOf(403, 404, 410))
|
||||
// delete home set only if it was not accessible (40x)
|
||||
itHomeSets.remove()
|
||||
}
|
||||
}
|
||||
|
||||
// check/refresh unconfirmed collections
|
||||
val itCollections = collections.entries.iterator()
|
||||
while (itCollections.hasNext()) {
|
||||
val (url, info) = itCollections.next()
|
||||
if (!info.confirmed)
|
||||
try {
|
||||
DavResource(httpClient, url).propfind(0, *CollectionInfo.DAV_PROPERTIES) { response, _ ->
|
||||
if (!response.isSuccess())
|
||||
return@propfind
|
||||
|
||||
val info = CollectionInfo(response)
|
||||
info.confirmed = true
|
||||
|
||||
// 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()
|
||||
val dav = DavResource(client, url)
|
||||
when (service.type) {
|
||||
Service.TYPE_CARDDAV ->
|
||||
try {
|
||||
dav.propfind(0, DisplayName.NAME, AddressbookHomeSet.NAME, GroupMembership.NAME) { response, _ ->
|
||||
response[AddressbookHomeSet::class.java]?.let { homeSet ->
|
||||
for (href in homeSet.hrefs)
|
||||
dav.location.resolve(href)?.let {
|
||||
val foundUrl = UrlUtils.withTrailingSlash(it)
|
||||
homeSets[foundUrl] = HomeSet(0, service.id, foundUrl)
|
||||
}
|
||||
} 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 (recurse)
|
||||
findRelated(dav.location, response)
|
||||
}
|
||||
|
||||
// restore selections
|
||||
for (url in selectedCollections)
|
||||
collections[url]?.let { it.selected = true }
|
||||
} catch (e: HttpException) {
|
||||
if (e.code/100 == 4)
|
||||
Logger.log.log(Level.INFO, "Ignoring Client Error 4xx while looking for addressbook home sets", e)
|
||||
else
|
||||
throw e
|
||||
}
|
||||
Service.TYPE_CALDAV -> {
|
||||
try {
|
||||
dav.propfind(0, DisplayName.NAME, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME) { response, _ ->
|
||||
response[CalendarHomeSet::class.java]?.let { homeSet ->
|
||||
for (href in homeSet.hrefs)
|
||||
dav.location.resolve(href)?.let {
|
||||
val foundUrl = UrlUtils.withTrailingSlash(it)
|
||||
homeSets[foundUrl] = HomeSet(0, service.id, foundUrl)
|
||||
}
|
||||
}
|
||||
|
||||
if (recurse)
|
||||
findRelated(dav.location, response)
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
if (e.code/100 == 4)
|
||||
Logger.log.log(Level.INFO, "Ignoring Client Error 4xx while looking for calendar home sets", e)
|
||||
else
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (resource in related)
|
||||
queryHomeSets(client, resource, false)
|
||||
}
|
||||
|
||||
@Transaction
|
||||
fun saveHomesets() {
|
||||
DaoTools(homeSetDao).syncAll(
|
||||
homeSetDao.getByService(serviceId),
|
||||
homeSets,
|
||||
{ it.url })
|
||||
}
|
||||
|
||||
@Transaction
|
||||
fun saveCollections() {
|
||||
DaoTools(collectionDao).syncAll(
|
||||
collectionDao.getByService(serviceId),
|
||||
collections, { it.url }) { new, old ->
|
||||
new.forceReadOnly = old.forceReadOnly
|
||||
new.sync = old.sync
|
||||
}
|
||||
}
|
||||
|
||||
fun saveResults() {
|
||||
saveHomesets()
|
||||
saveCollections()
|
||||
}
|
||||
|
||||
try {
|
||||
Logger.log.info("Refreshing ${service.type} collections of service #$service")
|
||||
|
||||
// cancel previous notification
|
||||
NotificationManagerCompat.from(this)
|
||||
.cancel(service.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS)
|
||||
|
||||
// create authenticating OkHttpClient (credentials taken from account settings)
|
||||
HttpClient.Builder(this, AccountSettings(this, account))
|
||||
.setForeground(true)
|
||||
.build().use { client ->
|
||||
val httpClient = client.okHttpClient
|
||||
|
||||
// refresh home set list (from principal)
|
||||
service.principal?.let { principalUrl ->
|
||||
Logger.log.fine("Querying principal $principalUrl for home sets")
|
||||
queryHomeSets(httpClient, principalUrl)
|
||||
}
|
||||
|
||||
db.beginTransactionNonExclusive()
|
||||
try {
|
||||
saveHomeSets()
|
||||
saveCollections()
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
// now refresh homesets and their member collections
|
||||
val itHomeSets = homeSets.iterator()
|
||||
while (itHomeSets.hasNext()) {
|
||||
val homeSet = itHomeSets.next()
|
||||
Logger.log.fine("Listing home set ${homeSet.key}")
|
||||
|
||||
try {
|
||||
DavResource(httpClient, homeSet.key).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.value.displayName = response[DisplayName::class.java]?.displayName
|
||||
homeSet.value.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.confirmed = true
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
} 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 {
|
||||
DavResource(httpClient, url).propfind(0, *DAV_COLLECTION_PROPERTIES) { response, _ ->
|
||||
if (!response.isSuccess())
|
||||
return@propfind
|
||||
|
||||
val debugIntent = Intent(this, DebugInfoActivity::class.java)
|
||||
debugIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
|
||||
debugIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account)
|
||||
val collection = Collection.fromDavResponse(response) ?: return@propfind
|
||||
collection.confirmed = true
|
||||
|
||||
val notify = NotificationUtils.newBuilder(this)
|
||||
.setSmallIcon(R.drawable.ic_sync_error_notification)
|
||||
.setContentTitle(getString(R.string.dav_service_refresh_failed))
|
||||
.setContentText(getString(R.string.dav_service_refresh_couldnt_refresh))
|
||||
.setContentIntent(PendingIntent.getActivity(this, 0, debugIntent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.setSubText(account.name)
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.build()
|
||||
NotificationManagerCompat.from(this)
|
||||
.notify(service.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS, notify)
|
||||
} finally {
|
||||
runningRefresh.remove(service)
|
||||
refreshingStatusListeners.forEach { it.get()?.onDavRefreshStatusChanged(service, false) }
|
||||
// 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()
|
||||
}
|
||||
} 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveResults()
|
||||
|
||||
} catch(e: InvalidAccountException) {
|
||||
Logger.log.log(Level.SEVERE, "Invalid account", e)
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't refresh collection list", e)
|
||||
|
||||
val debugIntent = Intent(this, DebugInfoActivity::class.java)
|
||||
debugIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
|
||||
debugIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account)
|
||||
|
||||
val notify = NotificationUtils.newBuilder(this, NotificationUtils.CHANNEL_GENERAL)
|
||||
.setSmallIcon(R.drawable.ic_sync_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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,17 @@
|
||||
|
||||
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 androidx.core.content.getSystemService
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import okhttp3.HttpUrl
|
||||
import org.xbill.DNS.*
|
||||
import java.util.*
|
||||
@@ -22,15 +28,17 @@ import java.util.*
|
||||
*/
|
||||
object DavUtils {
|
||||
|
||||
@Suppress("FunctionName")
|
||||
fun ARGBtoCalDAVColor(colorWithAlpha: Int): String {
|
||||
val alpha = (colorWithAlpha shr 24) and 0xFF
|
||||
val color = colorWithAlpha and 0xFFFFFF
|
||||
return String.format("#%06X%02X", color, alpha)
|
||||
}
|
||||
|
||||
|
||||
fun lastSegmentOfUrl(url: HttpUrl): String {
|
||||
// the list returned by HttpUrl.pathSegments() is unmodifiable, so we have to create a copy
|
||||
val segments = LinkedList<String>(url.pathSegments())
|
||||
val segments = LinkedList<String>(url.pathSegments)
|
||||
segments.reverse()
|
||||
|
||||
return segments.firstOrNull { it.isNotEmpty() } ?: "/"
|
||||
@@ -43,27 +51,65 @@ object DavUtils {
|
||||
The current version of dnsjava relies on these properties to find the default name servers,
|
||||
so we have to add the servers explicitly (fortunately, there's an Android API to
|
||||
get the active DNS servers). */
|
||||
val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val connectivity = context.getSystemService<ConnectivityManager>()!!
|
||||
val activeLink = connectivity.getLinkProperties(connectivity.activeNetwork)
|
||||
val simpleResolvers = activeLink.dnsServers.map {
|
||||
Logger.log.fine("Using DNS server ${it.hostAddress}")
|
||||
val resolver = SimpleResolver()
|
||||
resolver.setAddress(it)
|
||||
resolver
|
||||
}
|
||||
val resolver = ExtendedResolver(simpleResolvers.toTypedArray())
|
||||
lookup.setResolver(resolver)
|
||||
if (activeLink != null) {
|
||||
// get DNS servers of active network link and set them for dnsjava so that it can send SRV queries
|
||||
val simpleResolvers = activeLink.dnsServers.map {
|
||||
Logger.log.fine("Using DNS server ${it.hostAddress}")
|
||||
val resolver = SimpleResolver()
|
||||
resolver.setAddress(it)
|
||||
resolver
|
||||
}
|
||||
val resolver = ExtendedResolver(simpleResolvers.toTypedArray())
|
||||
lookup.setResolver(resolver)
|
||||
} else
|
||||
Logger.log.severe("Couldn't determine DNS servers, dnsjava queries (SRV/TXT records) won't work")
|
||||
}
|
||||
}
|
||||
|
||||
fun selectSRVRecord(records: Array<Record>?): SRVRecord? {
|
||||
val srvRecords = records?.filterIsInstance(SRVRecord::class.java)
|
||||
srvRecords?.let {
|
||||
if (it.size > 1)
|
||||
Logger.log.warning("Multiple SRV records not supported yet; using first one")
|
||||
return it.firstOrNull()
|
||||
fun selectSRVRecord(records: Array<out Record>): SRVRecord? {
|
||||
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 }.min()
|
||||
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
|
||||
}
|
||||
return null
|
||||
|
||||
val selector = (0..runningWeight).random()
|
||||
return map.ceilingEntry(selector)!!.value
|
||||
}
|
||||
|
||||
fun pathsFromTXTRecords(records: Array<Record>?): List<String> {
|
||||
@@ -79,4 +125,19 @@ object DavUtils {
|
||||
return paths
|
||||
}
|
||||
|
||||
|
||||
fun requestSync(context: Context, account: Account) {
|
||||
val authorities = arrayOf(
|
||||
context.getString(R.string.address_books_authority),
|
||||
CalendarContract.AUTHORITY,
|
||||
TaskProvider.ProviderName.OpenTasks.authority
|
||||
)
|
||||
|
||||
for (authority in authorities) {
|
||||
val extras = Bundle(2)
|
||||
extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true) // manual sync
|
||||
extras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true) // run immediately (don't queue)
|
||||
ContentResolver.requestSync(account, authority, extras)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,16 +12,15 @@ import android.content.Context
|
||||
import android.os.Build
|
||||
import android.security.KeyChain
|
||||
import at.bitfire.cert4android.CustomCertManager
|
||||
import at.bitfire.dav4android.BasicDigestAuthHandler
|
||||
import at.bitfire.dav4android.Constants
|
||||
import at.bitfire.dav4android.UrlUtils
|
||||
import at.bitfire.dav4jvm.BasicDigestAuthHandler
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.Credentials
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import okhttp3.Cache
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
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 okhttp3.internal.tls.OkHostnameVerifier
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import java.io.File
|
||||
@@ -34,10 +33,7 @@ import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Level
|
||||
import javax.net.ssl.KeyManager
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509ExtendedKeyManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
import javax.net.ssl.*
|
||||
|
||||
class HttpClient private constructor(
|
||||
val okHttpClient: OkHttpClient,
|
||||
@@ -45,34 +41,48 @@ class HttpClient private constructor(
|
||||
): 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.Builder()
|
||||
val sharedClient: OkHttpClient = OkHttpClient.Builder()
|
||||
// set timeouts
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
|
||||
// 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()!!
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
override fun close() {
|
||||
okHttpClient.cache?.close()
|
||||
certManager?.close()
|
||||
}
|
||||
|
||||
class Builder(
|
||||
val context: Context? = null,
|
||||
val settings: ISettings? = null,
|
||||
accountSettings: AccountSettings? = null,
|
||||
val logger: java.util.logging.Logger = Logger.log
|
||||
) {
|
||||
private var certManager: CustomCertManager? = null
|
||||
private var certificateAlias: String? = null
|
||||
private var cache: Cache? = null
|
||||
|
||||
private val orig = sharedClient.newBuilder()
|
||||
|
||||
@@ -82,39 +92,41 @@ class HttpClient private constructor(
|
||||
|
||||
// add network logging, if requested
|
||||
if (logger.isLoggable(Level.FINEST)) {
|
||||
val loggingInterceptor = HttpLoggingInterceptor(HttpLoggingInterceptor.Logger {
|
||||
message -> logger.finest(message)
|
||||
val loggingInterceptor = HttpLoggingInterceptor(object: HttpLoggingInterceptor.Logger {
|
||||
override fun log(message: String) {
|
||||
logger.finest(message)
|
||||
}
|
||||
})
|
||||
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
|
||||
orig.addInterceptor(loggingInterceptor)
|
||||
}
|
||||
|
||||
settings?.let {
|
||||
if (context != null) {
|
||||
val settings = SettingsManager.getInstance(context)
|
||||
|
||||
// custom proxy support
|
||||
try {
|
||||
if (settings.getBoolean(App.OVERRIDE_PROXY, false)) {
|
||||
if (settings.getBoolean(Settings.OVERRIDE_PROXY) == true) {
|
||||
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)
|
||||
settings.getString(Settings.OVERRIDE_PROXY_HOST),
|
||||
settings.getInt(Settings.OVERRIDE_PROXY_PORT)
|
||||
)
|
||||
|
||||
val proxy = Proxy(Proxy.Type.HTTP, address)
|
||||
orig.proxy(proxy)
|
||||
Logger.log.log(Level.INFO, "Using proxy", proxy)
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Can't set proxy, ignoring", e)
|
||||
}
|
||||
|
||||
context?.let {
|
||||
if (BuildConfig.customCerts)
|
||||
customCertManager(CustomCertManager(context, BuildConfig.customCertsUI, !settings.getBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, false)))
|
||||
customCertManager(CustomCertManager(context, true /*BuildConfig.customCertsUI*/,
|
||||
!(settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES))))
|
||||
}
|
||||
|
||||
// use account settings for authentication
|
||||
accountSettings?.let {
|
||||
addAuthentication(null, it.credentials())
|
||||
}
|
||||
}
|
||||
// use account settings for authentication
|
||||
accountSettings?.let {
|
||||
addAuthentication(null, it.credentials())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,14 +134,13 @@ class HttpClient private constructor(
|
||||
addAuthentication(host, credentials)
|
||||
}
|
||||
|
||||
fun withDiskCache(): Builder {
|
||||
val context = context ?: throw IllegalArgumentException("Context is required to find the cache directory")
|
||||
for (dir in arrayOf(context.externalCacheDir, context.cacheDir)) {
|
||||
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, 10*1024*1024))
|
||||
orig.cache(Cache(cacheDir, DISK_CACHE_MAX_SIZE))
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -144,7 +155,6 @@ class HttpClient private constructor(
|
||||
fun customCertManager(manager: CustomCertManager) {
|
||||
certManager = manager
|
||||
}
|
||||
|
||||
fun setForeground(foreground: Boolean): Builder {
|
||||
certManager?.appInForeground = foreground
|
||||
return this
|
||||
@@ -171,18 +181,20 @@ class HttpClient private constructor(
|
||||
factory.trustManagers.first() as X509TrustManager
|
||||
}()
|
||||
|
||||
val hostnameVerifier = certManager?.hostnameVerifier(OkHostnameVerifier.INSTANCE)
|
||||
?: OkHostnameVerifier.INSTANCE
|
||||
val hostnameVerifier = certManager?.hostnameVerifier(OkHostnameVerifier)
|
||||
?: OkHostnameVerifier
|
||||
|
||||
var keyManager: KeyManager? = null
|
||||
try {
|
||||
certificateAlias?.let { alias ->
|
||||
// get client certificate and private key
|
||||
certificateAlias?.let { alias ->
|
||||
try {
|
||||
val context = requireNotNull(context)
|
||||
|
||||
// get provider certificate and private key
|
||||
val certs = KeyChain.getCertificateChain(context, alias) ?: return@let
|
||||
val key = KeyChain.getPrivateKey(context, alias) ?: return@let
|
||||
logger.fine("Using client certificate $alias for authentication (chain length: ${certs.size})")
|
||||
logger.fine("Using provider certificate $alias for authentication (chain length: ${certs.size})")
|
||||
|
||||
// create Android KeyStore (performs key operations without revealing secret data to DAVdroid)
|
||||
// create Android KeyStore (performs key operations without revealing secret data to DAVx5)
|
||||
val keyStore = KeyStore.getInstance("AndroidKeyStore")
|
||||
keyStore.load(null)
|
||||
|
||||
@@ -203,12 +215,21 @@ class HttpClient private constructor(
|
||||
override fun getPrivateKey(forAlias: String?) =
|
||||
key.takeIf { forAlias == alias }
|
||||
}
|
||||
|
||||
// HTTP/2 doesn't support client certificates (yet)
|
||||
// see https://tools.ietf.org/html/draft-ietf-httpbis-http2-secondary-certs-04
|
||||
orig.protocols(listOf(Protocol.HTTP_1_1))
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.SEVERE, "Couldn't set up provider certificate authentication", e)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.SEVERE, "Couldn't set up client certificate authentication", e)
|
||||
}
|
||||
|
||||
orig.sslSocketFactory(CustomTlsSocketFactory(keyManager, trustManager), trustManager)
|
||||
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)
|
||||
@@ -218,17 +239,11 @@ class HttpClient private constructor(
|
||||
|
||||
|
||||
private object UserAgentInterceptor: Interceptor {
|
||||
|
||||
private val productName = when(BuildConfig.FLAVOR) {
|
||||
App.FLAVOR_ICLOUD -> "MultiSync for Cloud"
|
||||
App.FLAVOR_SOLDUPE -> "Soldupe Sync"
|
||||
else -> "DAVdroid"
|
||||
}
|
||||
|
||||
// use Locale.US because numbers may be encoded as non-ASCII characters in other locales
|
||||
private val userAgentDateFormat = SimpleDateFormat("yyyy/MM/dd", Locale.US)
|
||||
private val userAgentDate = userAgentDateFormat.format(Date(BuildConfig.buildTime))
|
||||
private val userAgent = "$productName/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4android; okhttp/${Constants.okHttpVersion}) Android/${Build.VERSION.RELEASE}"
|
||||
private val userAgent = "${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4jvm; " +
|
||||
"okhttp/${OkHttp.VERSION}) Android/${Build.VERSION.RELEASE}"
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val locale = Locale.getDefault()
|
||||
|
||||
@@ -32,7 +32,7 @@ class MemoryCookieStore: CookieJar {
|
||||
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
|
||||
}
|
||||
|
||||
77
app/src/main/java/at/bitfire/davdroid/OpenTasksWatcher.kt
Normal file
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.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.LocalTaskList
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.ical4android.TaskProvider.ProviderName.OpenTasks
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class OpenTasksWatcher(
|
||||
context: Context
|
||||
): PackageChangedReceiver(context) {
|
||||
|
||||
companion object {
|
||||
|
||||
@WorkerThread
|
||||
fun updateTaskSync(context: Context) {
|
||||
val tasksInstalled = LocalTaskList.tasksProviderAvailable(context)
|
||||
Logger.log.info("App was launched or package was (in)installed; OpenTasks provider now available = $tasksInstalled")
|
||||
|
||||
var enabledAnyAccount = false
|
||||
|
||||
// check all accounts and (de)activate OpenTasks if a CalDAV service is defined
|
||||
val db = AppDatabase.getInstance(context)
|
||||
for (service in db.serviceDao().getByType(Service.TYPE_CALDAV)) {
|
||||
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||
try {
|
||||
val accountSettings = AccountSettings(context, account)
|
||||
val currentSyncable = ContentResolver.getIsSyncable(account, OpenTasks.authority)
|
||||
if (tasksInstalled) {
|
||||
if (currentSyncable <= 0) {
|
||||
Logger.log.info("Enabling OpenTasks sync for $account")
|
||||
ContentResolver.setIsSyncable(account, OpenTasks.authority, 1)
|
||||
accountSettings.setSyncInterval(OpenTasks.authority, Constants.DEFAULT_SYNC_INTERVAL)
|
||||
enabledAnyAccount = true
|
||||
}
|
||||
} else if (currentSyncable != 0) {
|
||||
Logger.log.info("Disabling OpenTasks sync for $account")
|
||||
ContentResolver.setIsSyncable(account, OpenTasks.authority, 0)
|
||||
}
|
||||
} catch (e: InvalidAccountException) {
|
||||
// account which is still mentioned in DB doesn't exist (anymore)
|
||||
}
|
||||
}
|
||||
|
||||
if (enabledAnyAccount && !PermissionUtils.havePermissions(context, PermissionUtils.TASKS_PERMISSIONS)) {
|
||||
Logger.log.warning("Tasks sync is now enabled for at least one account, but OpenTasks permissions are not granted")
|
||||
PermissionUtils.notifyPermissions(context, null)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
updateTaskSync(context)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,61 +1,25 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.ServiceDB.Services
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import android.content.IntentFilter
|
||||
|
||||
class PackageChangedReceiver: BroadcastReceiver() {
|
||||
abstract class PackageChangedReceiver(
|
||||
val context: Context
|
||||
): BroadcastReceiver(), AutoCloseable {
|
||||
|
||||
companion object {
|
||||
|
||||
fun updateTaskSync(context: Context) {
|
||||
val tasksInstalled = LocalTaskList.tasksProviderAvailable(context)
|
||||
Logger.log.info("Tasks provider available = $tasksInstalled")
|
||||
|
||||
// check all accounts and (de)activate OpenTasks if a CalDAV service is defined
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.readableDatabase
|
||||
|
||||
db.query(Services._TABLE, arrayOf(Services.ACCOUNT_NAME),
|
||||
"${Services.SERVICE}=?", arrayOf(Services.SERVICE_CALDAV), null, null, null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val account = Account(cursor.getString(0), context.getString(R.string.account_type))
|
||||
|
||||
if (tasksInstalled) {
|
||||
if (ContentResolver.getIsSyncable(account, 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)
|
||||
}
|
||||
} else
|
||||
ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 0)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
init {
|
||||
val filter = IntentFilter(Intent.ACTION_PACKAGE_ADDED).apply {
|
||||
addAction(Intent.ACTION_PACKAGE_CHANGED)
|
||||
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
|
||||
context.registerReceiver(this, filter)
|
||||
}
|
||||
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
updateTaskSync(context)
|
||||
override fun close() {
|
||||
context.unregisterReceiver(this)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
72
app/src/main/java/at/bitfire/davdroid/PermissionUtils.kt
Normal file
@@ -0,0 +1,72 @@
|
||||
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 androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.davdroid.ui.PermissionsActivity
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
|
||||
object PermissionUtils {
|
||||
|
||||
val CONTACT_PERMSSIONS = arrayOf(
|
||||
Manifest.permission.READ_CONTACTS,
|
||||
Manifest.permission.WRITE_CONTACTS
|
||||
)
|
||||
val CALENDAR_PERMISSIONS = arrayOf(
|
||||
Manifest.permission.READ_CALENDAR,
|
||||
Manifest.permission.WRITE_CALENDAR
|
||||
)
|
||||
val TASKS_PERMISSIONS = arrayOf(
|
||||
TaskProvider.PERMISSION_READ_TASKS,
|
||||
TaskProvider.PERMISSION_WRITE_TASKS
|
||||
)
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,45 +8,51 @@
|
||||
|
||||
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.preference.PreferenceManager
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import android.support.v4.app.NotificationManagerCompat
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.preference.PreferenceManager
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.AppSettingsActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import org.apache.commons.lang3.time.DateFormatUtils
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.logging.FileHandler
|
||||
import java.util.logging.Level
|
||||
|
||||
object Logger {
|
||||
object Logger : SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
const val LOG_TO_EXTERNAL_STORAGE = "log_to_external_storage"
|
||||
|
||||
val log = java.util.logging.Logger.getLogger("davdroid")!!
|
||||
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
|
||||
|
||||
fun initialize(context: Context) {
|
||||
fun initialize(app: Application) {
|
||||
context = app
|
||||
preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
preferences.registerOnSharedPreferenceChangeListener { _, s ->
|
||||
if (s == LOG_TO_EXTERNAL_STORAGE)
|
||||
reinitialize(context.applicationContext)
|
||||
}
|
||||
preferences.registerOnSharedPreferenceChangeListener(this)
|
||||
|
||||
reinitialize(context.applicationContext)
|
||||
reinitialize()
|
||||
}
|
||||
|
||||
private fun reinitialize(context: Context) {
|
||||
val logToFile = preferences.getBoolean(LOG_TO_EXTERNAL_STORAGE, false)
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
if (key == LOG_TO_FILE) {
|
||||
log.info("Logging settings changed; re-initializing logger")
|
||||
reinitialize()
|
||||
}
|
||||
}
|
||||
|
||||
private fun reinitialize() {
|
||||
val logToFile = preferences.getBoolean(LOG_TO_FILE, false)
|
||||
val logVerbose = logToFile || Log.isLoggable(log.name, Log.DEBUG)
|
||||
|
||||
log.info("Verbose logging: $logVerbose; to file: $logToFile")
|
||||
@@ -64,43 +70,67 @@ object Logger {
|
||||
// log to external file according to preferences
|
||||
if (logToFile) {
|
||||
val builder = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_DEBUG)
|
||||
builder .setSmallIcon(R.drawable.ic_sd_storage_notification)
|
||||
.setContentTitle(context.getString(R.string.logging_davdroid_file_logging))
|
||||
.setLocalOnly(true)
|
||||
builder .setSmallIcon(R.drawable.ic_sd_card_notify)
|
||||
.setContentTitle(context.getString(R.string.logging_notification_title))
|
||||
|
||||
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 logDir = debugDir(context) ?: return
|
||||
val logFile = File(logDir, "davx5-log.txt")
|
||||
|
||||
val fileHandler = FileHandler(fileName)
|
||||
fileHandler.formatter = PlainTextFormatter.DEFAULT
|
||||
rootLogger.addHandler(fileHandler)
|
||||
try {
|
||||
val fileHandler = FileHandler(logFile.toString(), true)
|
||||
fileHandler.formatter = PlainTextFormatter.DEFAULT
|
||||
rootLogger.addHandler(fileHandler)
|
||||
|
||||
val prefIntent = Intent(context, AppSettingsActivity::class.java)
|
||||
prefIntent.putExtra(AppSettingsActivity.EXTRA_SCROLL_TO, LOG_TO_EXTERNAL_STORAGE)
|
||||
val prefIntent = Intent(context, AppSettingsActivity::class.java)
|
||||
prefIntent.putExtra(AppSettingsActivity.EXTRA_SCROLL_TO, LOG_TO_FILE)
|
||||
prefIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
builder .setContentText(dir.path)
|
||||
.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)
|
||||
builder .setContentText(logDir.path)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setContentText(context.getString(R.string.logging_notification_text))
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0, prefIntent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.setOngoing(true)
|
||||
|
||||
} catch(e: IOException) {
|
||||
log.log(Level.SEVERE, "Couldn't create external log file", e)
|
||||
val message = context.getString(R.string.logging_couldnt_create_file, e.localizedMessage)
|
||||
builder .setContentText(message)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
}
|
||||
else
|
||||
builder.setContentText(context.getString(R.string.logging_no_external_storage))
|
||||
// add "Share" action
|
||||
val logFileUri = FileProvider.getUriForFile(context, context.getString(R.string.authority_debug_provider), logFile)
|
||||
log.fine("Now logging to file: $logFile -> $logFileUri")
|
||||
|
||||
val shareIntent = Intent(Intent.ACTION_SEND)
|
||||
shareIntent.putExtra(Intent.EXTRA_SUBJECT, "DAVx⁵ logs")
|
||||
shareIntent.putExtra(Intent.EXTRA_STREAM, logFileUri)
|
||||
shareIntent.type = "text/plain"
|
||||
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
val chooserIntent = Intent.createChooser(shareIntent, null)
|
||||
val shareAction = NotificationCompat.Action.Builder(R.drawable.ic_share_notify,
|
||||
context.getString(R.string.logging_notification_send_log),
|
||||
PendingIntent.getActivity(context, 0, chooserIntent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
builder.addAction(shareAction.build())
|
||||
} catch(e: IOException) {
|
||||
log.log(Level.SEVERE, "Couldn't create log file", e)
|
||||
Toast.makeText(context, context.getString(R.string.logging_couldnt_create_file), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
nm.notify(NotificationUtils.NOTIFY_EXTERNAL_FILE_LOGGING, builder.build())
|
||||
} else
|
||||
} else {
|
||||
nm.cancel(NotificationUtils.NOTIFY_EXTERNAL_FILE_LOGGING)
|
||||
|
||||
// delete old logs
|
||||
debugDir(context)?.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun debugDir(context: Context): File? {
|
||||
val dir = File(context.filesDir, "debug")
|
||||
if (dir.exists() && dir.isDirectory)
|
||||
return dir
|
||||
|
||||
if (dir.mkdir())
|
||||
return dir
|
||||
|
||||
Toast.makeText(context, context.getString(R.string.logging_couldnt_create_file), Toast.LENGTH_LONG).show()
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
216
app/src/main/java/at/bitfire/davdroid/model/AppDatabase.kt
Normal file
@@ -0,0 +1,216 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteException
|
||||
import android.database.sqlite.SQLiteQueryBuilder
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import at.bitfire.davdroid.AndroidSingleton
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
|
||||
@Suppress("ClassName")
|
||||
@Database(entities = [
|
||||
Service::class,
|
||||
HomeSet::class,
|
||||
Collection::class
|
||||
], exportSchema = true, version = 7)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class AppDatabase: RoomDatabase() {
|
||||
|
||||
abstract fun serviceDao(): ServiceDao
|
||||
abstract fun homeSetDao(): HomeSetDao
|
||||
abstract fun collectionDao(): CollectionDao
|
||||
|
||||
companion object: AndroidSingleton<AppDatabase>() {
|
||||
|
||||
override fun createInstance(context: Context): AppDatabase =
|
||||
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "services.db")
|
||||
.addMigrations(
|
||||
Migration1_2,
|
||||
Migration2_3,
|
||||
Migration3_4,
|
||||
Migration4_5,
|
||||
Migration5_6,
|
||||
Migration6_7
|
||||
)
|
||||
.fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing
|
||||
.build()
|
||||
|
||||
}
|
||||
|
||||
fun dump(sb: StringBuilder) {
|
||||
val db = openHelper.readableDatabase
|
||||
db.beginTransactionNonExclusive()
|
||||
|
||||
// iterate through all tables
|
||||
db.query(SQLiteQueryBuilder.buildQueryString(false, "sqlite_master", arrayOf("name"), "type='table'", null, null, null, null)).use { cursorTables ->
|
||||
while (cursorTables.moveToNext()) {
|
||||
val table = cursorTables.getString(0)
|
||||
sb.append(table).append("\n")
|
||||
db.query("SELECT * FROM $table").use { cursor ->
|
||||
// print columns
|
||||
val cols = cursor.columnCount
|
||||
sb.append("\t| ")
|
||||
for (i in 0 until cols)
|
||||
sb .append(" ")
|
||||
.append(cursor.getColumnName(i))
|
||||
.append(" |")
|
||||
sb.append("\n")
|
||||
|
||||
// print rows
|
||||
while (cursor.moveToNext()) {
|
||||
sb.append("\t| ")
|
||||
for (i in 0 until cols) {
|
||||
sb.append(" ")
|
||||
try {
|
||||
val value = cursor.getString(i)
|
||||
if (value != null)
|
||||
sb.append(value
|
||||
.replace("\r", "<CR>")
|
||||
.replace("\n", "<LF>"))
|
||||
else
|
||||
sb.append("<null>")
|
||||
|
||||
} catch (e: SQLiteException) {
|
||||
sb.append("<unprintable>")
|
||||
}
|
||||
sb.append(" |")
|
||||
}
|
||||
sb.append("\n")
|
||||
}
|
||||
sb.append("----------\n")
|
||||
}
|
||||
}
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// migrations
|
||||
|
||||
object Migration6_7: Migration(6, 7) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN privBind INTEGER NOT NULL DEFAULT 1")
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN displayName TEXT DEFAULT NULL")
|
||||
}
|
||||
}
|
||||
|
||||
object Migration5_6: Migration(5, 6) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
val sql = arrayOf(
|
||||
// migrate "services" to "service": rename columns, make id NOT NULL
|
||||
"CREATE TABLE service(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"accountName TEXT NOT NULL," +
|
||||
"type TEXT NOT NULL," +
|
||||
"principal TEXT DEFAULT NULL" +
|
||||
")",
|
||||
"CREATE UNIQUE INDEX index_service_accountName_type ON service(accountName, type)",
|
||||
"INSERT INTO service(id, accountName, type, principal) SELECT _id, accountName, service, principal FROM services",
|
||||
"DROP TABLE services",
|
||||
|
||||
// migrate "homesets" to "homeset": rename columns, make id NOT NULL
|
||||
"CREATE TABLE homeset(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"serviceId INTEGER NOT NULL," +
|
||||
"url TEXT NOT NULL," +
|
||||
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
|
||||
")",
|
||||
"CREATE UNIQUE INDEX index_homeset_serviceId_url ON homeset(serviceId, url)",
|
||||
"INSERT INTO homeset(id, serviceId, url) SELECT _id, serviceID, url FROM homesets",
|
||||
"DROP TABLE homesets",
|
||||
|
||||
// migrate "collections" to "collection": rename columns, make id NOT NULL
|
||||
"CREATE TABLE collection(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"serviceId INTEGER NOT NULL," +
|
||||
"type TEXT NOT NULL," +
|
||||
"url TEXT NOT NULL," +
|
||||
"privWriteContent INTEGER NOT NULL DEFAULT 1," +
|
||||
"privUnbind INTEGER NOT NULL DEFAULT 1," +
|
||||
"forceReadOnly INTEGER NOT NULL DEFAULT 0," +
|
||||
"displayName TEXT DEFAULT NULL," +
|
||||
"description TEXT DEFAULT NULL," +
|
||||
"color INTEGER DEFAULT NULL," +
|
||||
"timezone TEXT DEFAULT NULL," +
|
||||
"supportsVEVENT INTEGER DEFAULT NULL," +
|
||||
"supportsVTODO INTEGER DEFAULT NULL," +
|
||||
"supportsVJOURNAL INTEGER DEFAULT NULL," +
|
||||
"source TEXT DEFAULT NULL," +
|
||||
"sync INTEGER NOT NULL DEFAULT 0," +
|
||||
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
|
||||
")",
|
||||
"CREATE INDEX index_collection_serviceId_type ON collection(serviceId,type)",
|
||||
"INSERT INTO collection(id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync) " +
|
||||
"SELECT _id, serviceID, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync FROM collections",
|
||||
"DROP TABLE collections"
|
||||
)
|
||||
sql.forEach { db.execSQL(it) }
|
||||
}
|
||||
}
|
||||
|
||||
object Migration4_5: Migration(4, 5) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN privWriteContent INTEGER DEFAULT 0 NOT NULL")
|
||||
db.execSQL("UPDATE collections SET privWriteContent=NOT readOnly")
|
||||
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN privUnbind INTEGER DEFAULT 0 NOT NULL")
|
||||
db.execSQL("UPDATE collections SET privUnbind=NOT readOnly")
|
||||
|
||||
// there's no DROP COLUMN in SQLite, so just keep the "readOnly" column
|
||||
}
|
||||
}
|
||||
|
||||
object Migration3_4: Migration(3, 4) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN forceReadOnly INTEGER DEFAULT 0 NOT NULL")
|
||||
}
|
||||
}
|
||||
|
||||
object Migration2_3: Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
// We don't have access to the context in a Room migration now, so
|
||||
// we will just drop those settings from old DAVx5 versions.
|
||||
Logger.log.warning("Dropping settings distrustSystemCerts and overrideProxy*")
|
||||
|
||||
/*val edit = PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
try {
|
||||
db.query("settings", arrayOf("setting", "value"), null, null, null, null, null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
when (cursor.getString(0)) {
|
||||
"distrustSystemCerts" -> edit.putBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, cursor.getInt(1) != 0)
|
||||
"overrideProxy" -> edit.putBoolean(App.OVERRIDE_PROXY, cursor.getInt(1) != 0)
|
||||
"overrideProxyHost" -> edit.putString(App.OVERRIDE_PROXY_HOST, cursor.getString(1))
|
||||
"overrideProxyPort" -> edit.putInt(App.OVERRIDE_PROXY_PORT, cursor.getInt(1))
|
||||
|
||||
StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED ->
|
||||
edit.putBoolean(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED, cursor.getInt(1) != 0)
|
||||
StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED ->
|
||||
edit.putBoolean(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED, cursor.getInt(1) != 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
db.execSQL("DROP TABLE settings")
|
||||
} finally {
|
||||
edit.apply()
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
object Migration1_2: Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN type TEXT NOT NULL DEFAULT ''")
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN source TEXT DEFAULT NULL")
|
||||
db.execSQL("UPDATE collections SET type=(" +
|
||||
"SELECT CASE service WHEN ? THEN ? ELSE ? END " +
|
||||
"FROM services WHERE _id=collections.serviceID" +
|
||||
")",
|
||||
arrayOf("caldav", "CALENDAR", "ADDRESS_BOOK"))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
160
app/src/main/java/at/bitfire/davdroid/model/Collection.kt
Normal file
@@ -0,0 +1,160 @@
|
||||
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
|
||||
|
||||
@Entity(tableName = "collection",
|
||||
foreignKeys = [
|
||||
ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE)
|
||||
],
|
||||
indices = [
|
||||
Index("serviceId","type")
|
||||
]
|
||||
)
|
||||
data class Collection(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
override var id: Long = 0,
|
||||
|
||||
var serviceId: Long = 0,
|
||||
|
||||
var type: String,
|
||||
var url: HttpUrl,
|
||||
|
||||
var privWriteContent: Boolean = true,
|
||||
var privUnbind: Boolean = true,
|
||||
var forceReadOnly: Boolean = false,
|
||||
|
||||
var displayName: String? = null,
|
||||
var description: String? = null,
|
||||
|
||||
// CalDAV only
|
||||
var color: Int? = null,
|
||||
|
||||
/** timezone definition (full VTIMEZONE) - not a TZID! **/
|
||||
var timezone: String? = null,
|
||||
|
||||
/** whether the collection supports VEVENT; in case of calendars: null means true */
|
||||
var supportsVEVENT: Boolean? = null,
|
||||
|
||||
/** whether the collection supports VTODO; in case of calendars: null means true */
|
||||
var supportsVTODO: Boolean? = null,
|
||||
|
||||
/** whether the collection supports VJOURNAL; in case of calendars: null means true */
|
||||
var supportsVJOURNAL: Boolean? = null,
|
||||
|
||||
/** Webcal subscription source URL */
|
||||
var source: HttpUrl? = null,
|
||||
|
||||
/** whether this collection has been selected for synchronization */
|
||||
var sync: Boolean = false
|
||||
|
||||
): IdEntity() {
|
||||
|
||||
companion object {
|
||||
|
||||
const val TYPE_ADDRESSBOOK = "ADDRESS_BOOK"
|
||||
const val TYPE_CALENDAR = "CALENDAR"
|
||||
const val TYPE_WEBCAL = "WEBCAL"
|
||||
|
||||
/**
|
||||
* Generates a collection entity from a WebDAV response.
|
||||
* @param dav WebDAV response
|
||||
* @return null if the response doesn't represent a collection
|
||||
*/
|
||||
fun fromDavResponse(dav: Response): Collection? {
|
||||
val url = UrlUtils.withTrailingSlash(dav.href)
|
||||
val type: String = dav[ResourceType::class.java]?.let { resourceType ->
|
||||
when {
|
||||
resourceType.types.contains(ResourceType.ADDRESSBOOK) -> TYPE_ADDRESSBOOK
|
||||
resourceType.types.contains(ResourceType.CALENDAR) -> TYPE_CALENDAR
|
||||
resourceType.types.contains(ResourceType.SUBSCRIBED) -> TYPE_WEBCAL
|
||||
else -> null
|
||||
}
|
||||
} ?: return null
|
||||
|
||||
var privWriteContent = true
|
||||
var privUnbind = true
|
||||
dav[CurrentUserPrivilegeSet::class.java]?.let { privilegeSet ->
|
||||
privWriteContent = privilegeSet.mayWriteContent
|
||||
privUnbind = privilegeSet.mayUnbind
|
||||
}
|
||||
|
||||
var displayName: String? = null
|
||||
dav[DisplayName::class.java]?.let {
|
||||
if (!it.displayName.isNullOrEmpty())
|
||||
displayName = it.displayName
|
||||
}
|
||||
|
||||
var description: String? = null
|
||||
var color: Int? = null
|
||||
var timezone: String? = null
|
||||
var supportsVEVENT: Boolean? = null
|
||||
var supportsVTODO: Boolean? = null
|
||||
var supportsVJOURNAL: Boolean? = null
|
||||
var source: HttpUrl? = null
|
||||
when (type) {
|
||||
TYPE_ADDRESSBOOK -> {
|
||||
dav[AddressbookDescription::class.java]?.let { description = it.description }
|
||||
}
|
||||
TYPE_CALENDAR, TYPE_WEBCAL -> {
|
||||
dav[CalendarDescription::class.java]?.let { description = it.description }
|
||||
dav[CalendarColor::class.java]?.let { color = it.color }
|
||||
dav[CalendarTimezone::class.java]?.let { timezone = it.vTimeZone }
|
||||
|
||||
if (type == TYPE_CALENDAR) {
|
||||
supportsVEVENT = true
|
||||
supportsVTODO = true
|
||||
supportsVJOURNAL = true
|
||||
dav[SupportedCalendarComponentSet::class.java]?.let {
|
||||
supportsVEVENT = it.supportsEvents
|
||||
supportsVTODO = it.supportsTasks
|
||||
supportsVJOURNAL = it.supportsJournal
|
||||
}
|
||||
} else { // Type.WEBCAL
|
||||
dav[Source::class.java]?.let {
|
||||
source = it.hrefs.firstOrNull()?.let { rawHref ->
|
||||
val href = rawHref
|
||||
.replace("^webcal://".toRegex(), "http://")
|
||||
.replace("^webcals://".toRegex(), "https://")
|
||||
href.toHttpUrlOrNull()
|
||||
}
|
||||
}
|
||||
supportsVEVENT = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Collection(
|
||||
type = type,
|
||||
url = url,
|
||||
privWriteContent = privWriteContent,
|
||||
privUnbind = privUnbind,
|
||||
displayName = displayName,
|
||||
description = description,
|
||||
color = color,
|
||||
timezone = timezone,
|
||||
supportsVEVENT = supportsVEVENT,
|
||||
supportsVTODO = supportsVTODO,
|
||||
supportsVJOURNAL = supportsVJOURNAL,
|
||||
source = source
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// non-persistent properties
|
||||
@Ignore
|
||||
var confirmed: Boolean = false
|
||||
|
||||
|
||||
// calculated properties
|
||||
fun title() = displayName ?: DavUtils.lastSegmentOfUrl(url)
|
||||
fun readOnly() = forceReadOnly || !privWriteContent
|
||||
|
||||
}
|
||||
43
app/src/main/java/at/bitfire/davdroid/model/CollectionDao.kt
Normal file
@@ -0,0 +1,43 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.paging.DataSource
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface CollectionDao: SyncableDao<Collection> {
|
||||
|
||||
@Query("SELECT * FROM collection WHERE id=:id")
|
||||
fun get(id: Long): Collection?
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId")
|
||||
fun getByService(serviceId: Long): List<Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type")
|
||||
fun getByServiceAndType(serviceId: Long, type: String): List<Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type ORDER BY displayName, url")
|
||||
fun pageByServiceAndType(serviceId: Long, type: String): DataSource.Factory<Int, Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND sync ORDER BY displayName, url")
|
||||
fun getByServiceAndSync(serviceId: Long): List<Collection>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM collection WHERE serviceId=:serviceId AND sync")
|
||||
fun observeHasSyncByService(serviceId: Long): LiveData<Boolean>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND supportsVEVENT AND sync ORDER BY displayName, url")
|
||||
fun getSyncCalendars(serviceId: Long): List<Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND supportsVTODO AND sync ORDER BY displayName, url")
|
||||
fun getSyncTaskLists(serviceId: Long): List<Collection>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertOrReplace(collection: Collection)
|
||||
|
||||
@Insert
|
||||
fun insert(collection: Collection)
|
||||
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import at.bitfire.dav4android.Response
|
||||
import at.bitfire.dav4android.UrlUtils
|
||||
import at.bitfire.dav4android.property.*
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
/**
|
||||
* Represents a WebDAV collection.
|
||||
*
|
||||
* @constructor always appends a trailing slash to the URL
|
||||
*/
|
||||
data class CollectionInfo(
|
||||
|
||||
/**
|
||||
* URL of the collection (including trailing slash)
|
||||
*/
|
||||
val url: HttpUrl,
|
||||
|
||||
var id: Long? = null,
|
||||
var serviceID: Long? = null,
|
||||
|
||||
var type: Type? = null,
|
||||
|
||||
var readOnly: Boolean = false,
|
||||
var forceReadOnly: Boolean = false,
|
||||
var displayName: String? = null,
|
||||
var description: String? = null,
|
||||
var color: Int? = null,
|
||||
|
||||
var timeZone: String? = null,
|
||||
var supportsVEVENT: Boolean = false,
|
||||
var supportsVTODO: Boolean = false,
|
||||
var selected: Boolean = false,
|
||||
|
||||
// subscriptions
|
||||
var source: String? = null,
|
||||
|
||||
// non-persistent properties
|
||||
var confirmed: Boolean = false
|
||||
): Parcelable {
|
||||
|
||||
enum class Type {
|
||||
ADDRESS_BOOK,
|
||||
CALENDAR,
|
||||
WEBCAL // iCalendar subscription
|
||||
}
|
||||
|
||||
constructor(dav: Response): this(UrlUtils.withTrailingSlash(dav.href)) {
|
||||
dav[ResourceType::class.java]?.let { type ->
|
||||
when {
|
||||
type.types.contains(ResourceType.ADDRESSBOOK) -> this.type = Type.ADDRESS_BOOK
|
||||
type.types.contains(ResourceType.CALENDAR) -> this.type = Type.CALENDAR
|
||||
type.types.contains(ResourceType.SUBSCRIBED) -> this.type = Type.WEBCAL
|
||||
}
|
||||
}
|
||||
|
||||
dav[CurrentUserPrivilegeSet::class.java]?.let { privilegeSet ->
|
||||
readOnly = !privilegeSet.mayWriteContent
|
||||
}
|
||||
|
||||
dav[DisplayName::class.java]?.let {
|
||||
if (!it.displayName.isNullOrEmpty())
|
||||
displayName = it.displayName
|
||||
}
|
||||
|
||||
when (type) {
|
||||
Type.ADDRESS_BOOK -> {
|
||||
dav[AddressbookDescription::class.java]?.let { description = it.description }
|
||||
}
|
||||
Type.CALENDAR, Type.WEBCAL -> {
|
||||
dav[CalendarDescription::class.java]?.let { description = it.description }
|
||||
dav[CalendarColor::class.java]?.let { color = it.color }
|
||||
dav[CalendarTimezone::class.java]?.let { timeZone = it.vTimeZone }
|
||||
|
||||
if (type == Type.CALENDAR) {
|
||||
supportsVEVENT = true
|
||||
supportsVTODO = true
|
||||
dav[SupportedCalendarComponentSet::class.java]?.let {
|
||||
supportsVEVENT = it.supportsEvents
|
||||
supportsVTODO = it.supportsTasks
|
||||
}
|
||||
} else { // Type.WEBCAL
|
||||
dav[Source::class.java]?.let { source = it.hrefs.firstOrNull() }
|
||||
supportsVEVENT = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
constructor(values: ContentValues): this(UrlUtils.withTrailingSlash(HttpUrl.parse(values.getAsString(Collections.URL))!!)) {
|
||||
id = values.getAsLong(Collections.ID)
|
||||
serviceID = values.getAsLong(Collections.SERVICE_ID)
|
||||
type = try {
|
||||
Type.valueOf(values.getAsString(Collections.TYPE))
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
readOnly = values.getAsInteger(Collections.READ_ONLY) != 0
|
||||
forceReadOnly = values.getAsInteger(Collections.FORCE_READ_ONLY) != 0
|
||||
displayName = values.getAsString(Collections.DISPLAY_NAME)
|
||||
description = values.getAsString(Collections.DESCRIPTION)
|
||||
|
||||
color = values.getAsInteger(Collections.COLOR)
|
||||
|
||||
timeZone = values.getAsString(Collections.TIME_ZONE)
|
||||
supportsVEVENT = getAsBooleanOrNull(values, Collections.SUPPORTS_VEVENT) ?: false
|
||||
supportsVTODO = getAsBooleanOrNull(values, Collections.SUPPORTS_VTODO) ?: false
|
||||
|
||||
source = values.getAsString(Collections.SOURCE)
|
||||
|
||||
selected = values.getAsInteger(Collections.SYNC) != 0
|
||||
}
|
||||
|
||||
fun toDB(): ContentValues {
|
||||
val values = ContentValues()
|
||||
// Collections.SERVICE_ID is never changed
|
||||
type?.let { values.put(Collections.TYPE, it.name) }
|
||||
|
||||
values.put(Collections.URL, url.toString())
|
||||
values.put(Collections.READ_ONLY, if (readOnly) 1 else 0)
|
||||
values.put(Collections.FORCE_READ_ONLY, if (forceReadOnly) 1 else 0)
|
||||
values.put(Collections.DISPLAY_NAME, displayName)
|
||||
values.put(Collections.DESCRIPTION, description)
|
||||
values.put(Collections.COLOR, color)
|
||||
|
||||
values.put(Collections.TIME_ZONE, timeZone)
|
||||
values.put(Collections.SUPPORTS_VEVENT, if (supportsVEVENT) 1 else 0)
|
||||
values.put(Collections.SUPPORTS_VTODO, if (supportsVTODO) 1 else 0)
|
||||
|
||||
values.put(Collections.SOURCE, source)
|
||||
|
||||
values.put(Collections.SYNC, if (selected) 1 else 0)
|
||||
return values
|
||||
}
|
||||
|
||||
|
||||
private fun getAsBooleanOrNull(values: ContentValues, field: String): Boolean? {
|
||||
val i = values.getAsInteger(field)
|
||||
return if (i == null)
|
||||
null
|
||||
else
|
||||
(i != 0)
|
||||
}
|
||||
|
||||
|
||||
override fun describeContents(): Int = 0
|
||||
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||
fun<T> writeOrNull(value: T?, write: (T) -> Unit) {
|
||||
if (value == null)
|
||||
dest.writeByte(0)
|
||||
else {
|
||||
dest.writeByte(1)
|
||||
write(value)
|
||||
}
|
||||
}
|
||||
|
||||
dest.writeString(url.toString())
|
||||
|
||||
writeOrNull(id) { dest.writeLong(it) }
|
||||
writeOrNull(serviceID) { dest.writeLong(it) }
|
||||
|
||||
dest.writeString(type?.name)
|
||||
|
||||
dest.writeByte(if (readOnly) 1 else 0)
|
||||
dest.writeByte(if (forceReadOnly) 1 else 0)
|
||||
dest.writeString(displayName)
|
||||
dest.writeString(description)
|
||||
writeOrNull(color) { dest.writeInt(it) }
|
||||
|
||||
dest.writeString(timeZone)
|
||||
dest.writeByte(if (supportsVEVENT) 1 else 0)
|
||||
dest.writeByte(if (supportsVTODO) 1 else 0)
|
||||
dest.writeByte(if (selected) 1 else 0)
|
||||
|
||||
dest.writeString(source)
|
||||
|
||||
dest.writeByte(if (confirmed) 1 else 0)
|
||||
}
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<CollectionInfo> {
|
||||
|
||||
val DAV_PROPERTIES = arrayOf(
|
||||
ResourceType.NAME,
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
DisplayName.NAME,
|
||||
AddressbookDescription.NAME, SupportedAddressData.NAME,
|
||||
CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME,
|
||||
Source.NAME
|
||||
)
|
||||
|
||||
override fun createFromParcel(parcel: Parcel): CollectionInfo {
|
||||
fun<T> readOrNull(parcel: Parcel, read: () -> T): T? {
|
||||
return if (parcel.readByte() == 0.toByte())
|
||||
null
|
||||
else
|
||||
read()
|
||||
}
|
||||
|
||||
return CollectionInfo(
|
||||
HttpUrl.parse(parcel.readString())!!,
|
||||
|
||||
readOrNull(parcel) { parcel.readLong() },
|
||||
readOrNull(parcel) { parcel.readLong() },
|
||||
|
||||
parcel.readString()?.let { Type.valueOf(it) },
|
||||
|
||||
parcel.readByte() != 0.toByte(),
|
||||
parcel.readByte() != 0.toByte(),
|
||||
parcel.readString(),
|
||||
parcel.readString(),
|
||||
readOrNull(parcel) { parcel.readInt() },
|
||||
|
||||
parcel.readString(),
|
||||
parcel.readByte() != 0.toByte(),
|
||||
parcel.readByte() != 0.toByte(),
|
||||
parcel.readByte() != 0.toByte(),
|
||||
|
||||
parcel.readString(),
|
||||
|
||||
parcel.readByte() != 0.toByte()
|
||||
)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int) = arrayOfNulls<CollectionInfo>(size)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
17
app/src/main/java/at/bitfire/davdroid/model/Converters.kt
Normal file
@@ -0,0 +1,17 @@
|
||||
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?.let { it.toHttpUrlOrNull() }
|
||||
|
||||
}
|
||||
@@ -8,13 +8,11 @@
|
||||
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
class Credentials(
|
||||
val userName: String? = null,
|
||||
val password: String? = null,
|
||||
val certificateAlias: String? = null
|
||||
): Serializable {
|
||||
) {
|
||||
|
||||
enum class Type {
|
||||
UsernamePassword,
|
||||
|
||||
41
app/src/main/java/at/bitfire/davdroid/model/DaoTools.kt
Normal file
@@ -0,0 +1,41 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
insert(remainingNew.values.toList())
|
||||
}
|
||||
|
||||
}
|
||||
28
app/src/main/java/at/bitfire/davdroid/model/HomeSet.kt
Normal file
@@ -0,0 +1,28 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
@Entity(tableName = "homeset",
|
||||
foreignKeys = [
|
||||
ForeignKey(entity = Service::class, parentColumns = arrayOf("id"), childColumns = arrayOf("serviceId"), onDelete = ForeignKey.CASCADE)
|
||||
],
|
||||
indices = [
|
||||
// index by service; no duplicate URLs per service
|
||||
Index("serviceId", "url", unique = true)
|
||||
]
|
||||
)
|
||||
data class HomeSet(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
override var id: Long,
|
||||
|
||||
var serviceId: Long,
|
||||
var url: HttpUrl,
|
||||
|
||||
var privBind: Boolean = true,
|
||||
|
||||
var displayName: String? = null
|
||||
): IdEntity()
|
||||
21
app/src/main/java/at/bitfire/davdroid/model/HomeSetDao.kt
Normal file
@@ -0,0 +1,21 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface HomeSetDao: SyncableDao<HomeSet> {
|
||||
|
||||
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId")
|
||||
fun getByService(serviceId: Long): List<HomeSet>
|
||||
|
||||
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND privBind")
|
||||
fun getBindableByService(serviceId: Long): List<HomeSet>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertOrReplace(homeSet: HomeSet): Long
|
||||
|
||||
|
||||
}
|
||||
5
app/src/main/java/at/bitfire/davdroid/model/IdEntity.kt
Normal file
@@ -0,0 +1,5 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
abstract class IdEntity {
|
||||
abstract var id: Long
|
||||
}
|
||||
28
app/src/main/java/at/bitfire/davdroid/model/Service.kt
Normal file
@@ -0,0 +1,28 @@
|
||||
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"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteException
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import android.preference.PreferenceManager
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.ui.StartupDialogFragment
|
||||
import java.util.logging.Level
|
||||
|
||||
class ServiceDB {
|
||||
|
||||
object Services {
|
||||
const val _TABLE = "services"
|
||||
const val ID = "_id"
|
||||
const val ACCOUNT_NAME = "accountName"
|
||||
const val SERVICE = "service"
|
||||
const val PRINCIPAL = "principal"
|
||||
|
||||
// allowed values for SERVICE column
|
||||
const val SERVICE_CALDAV = "caldav"
|
||||
const val SERVICE_CARDDAV = "carddav"
|
||||
}
|
||||
|
||||
object HomeSets {
|
||||
const val _TABLE = "homesets"
|
||||
const val ID = "_id"
|
||||
const val SERVICE_ID = "serviceID"
|
||||
const val URL = "url"
|
||||
}
|
||||
|
||||
object Collections {
|
||||
const val _TABLE = "collections"
|
||||
const val ID = "_id"
|
||||
const val TYPE = "type"
|
||||
const val SERVICE_ID = "serviceID"
|
||||
const val URL = "url"
|
||||
const val READ_ONLY = "readOnly"
|
||||
const val FORCE_READ_ONLY = "forceReadOnly"
|
||||
const val DISPLAY_NAME = "displayName"
|
||||
const val DESCRIPTION = "description"
|
||||
const val COLOR = "color"
|
||||
const val TIME_ZONE = "timezone"
|
||||
const val SUPPORTS_VEVENT = "supportsVEVENT"
|
||||
const val SUPPORTS_VTODO = "supportsVTODO"
|
||||
const val SOURCE = "source"
|
||||
const val SYNC = "sync"
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun onRenameAccount(db: SQLiteDatabase, oldName: String, newName: String) {
|
||||
val values = ContentValues(1)
|
||||
values.put(Services.ACCOUNT_NAME, newName)
|
||||
db.updateWithOnConflict(Services._TABLE, values, Services.ACCOUNT_NAME + "=?", arrayOf(oldName), SQLiteDatabase.CONFLICT_REPLACE)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class OpenHelper(
|
||||
val context: Context
|
||||
): SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION), AutoCloseable {
|
||||
|
||||
companion object {
|
||||
const val DATABASE_NAME = "services.db"
|
||||
const val DATABASE_VERSION = 4
|
||||
}
|
||||
|
||||
override fun onConfigure(db: SQLiteDatabase) {
|
||||
setWriteAheadLoggingEnabled(true)
|
||||
db.setForeignKeyConstraintsEnabled(true)
|
||||
}
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
Logger.log.info("Creating database " + db.path)
|
||||
|
||||
db.execSQL("CREATE TABLE ${Services._TABLE}(" +
|
||||
"${Services.ID} INTEGER PRIMARY KEY AUTOINCREMENT," +
|
||||
"${Services.ACCOUNT_NAME} TEXT NOT NULL," +
|
||||
"${Services.SERVICE} TEXT NOT NULL," +
|
||||
"${Services.PRINCIPAL} TEXT NULL)")
|
||||
db.execSQL("CREATE UNIQUE INDEX services_account ON ${Services._TABLE} (${Services.ACCOUNT_NAME},${Services.SERVICE})")
|
||||
|
||||
db.execSQL("CREATE TABLE ${HomeSets._TABLE}(" +
|
||||
"${HomeSets.ID} INTEGER PRIMARY KEY AUTOINCREMENT," +
|
||||
"${HomeSets.SERVICE_ID} INTEGER NOT NULL REFERENCES ${Services._TABLE} ON DELETE CASCADE," +
|
||||
"${HomeSets.URL} TEXT NOT NULL)")
|
||||
db.execSQL("CREATE UNIQUE INDEX homesets_service_url ON ${HomeSets._TABLE}(${HomeSets.SERVICE_ID},${HomeSets.URL})")
|
||||
|
||||
db.execSQL("CREATE TABLE ${Collections._TABLE}(" +
|
||||
"${Collections.ID} INTEGER PRIMARY KEY AUTOINCREMENT," +
|
||||
"${Collections.SERVICE_ID} INTEGER NOT NULL REFERENCES ${Services._TABLE} ON DELETE CASCADE," +
|
||||
"${Collections.TYPE} TEXT NOT NULL," +
|
||||
"${Collections.URL} TEXT NOT NULL," +
|
||||
"${Collections.READ_ONLY} INTEGER DEFAULT 0 NOT NULL," +
|
||||
"${Collections.FORCE_READ_ONLY} INTEGER DEFAULT 0 NOT NULL," +
|
||||
"${Collections.DISPLAY_NAME} TEXT NULL," +
|
||||
"${Collections.DESCRIPTION} TEXT NULL," +
|
||||
"${Collections.COLOR} INTEGER NULL," +
|
||||
"${Collections.TIME_ZONE} TEXT NULL," +
|
||||
"${Collections.SUPPORTS_VEVENT} INTEGER NULL," +
|
||||
"${Collections.SUPPORTS_VTODO} INTEGER NULL," +
|
||||
"${Collections.SOURCE} TEXT NULL," +
|
||||
"${Collections.SYNC} INTEGER DEFAULT 0 NOT NULL)")
|
||||
db.execSQL("CREATE UNIQUE INDEX collections_service_url ON ${Collections._TABLE}(${Collections.SERVICE_ID},${Collections.URL})")
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
for (upgradeFrom in oldVersion until newVersion) {
|
||||
val upgradeTo = upgradeFrom + 1
|
||||
Logger.log.info("Upgrading database from version $upgradeFrom to $upgradeTo")
|
||||
try {
|
||||
val upgradeProc = this::class.java.getDeclaredMethod("upgrade_${upgradeFrom}_$upgradeTo", SQLiteDatabase::class.java)
|
||||
upgradeProc.invoke(this, db)
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't upgrade database", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun upgrade_3_4(db: SQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE ${Collections._TABLE} ADD COLUMN ${Collections.FORCE_READ_ONLY} INTEGER DEFAULT 0 NOT NULL")
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun upgrade_2_3(db: SQLiteDatabase) {
|
||||
val edit = PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
try {
|
||||
db.query("settings", arrayOf("setting", "value"), null, null, null, null, null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
when (cursor.getString(0)) {
|
||||
"distrustSystemCerts" -> edit.putBoolean(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()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun upgrade_1_2(db: SQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE ${Collections._TABLE} ADD COLUMN ${Collections.TYPE} TEXT NOT NULL DEFAULT ''")
|
||||
db.execSQL("ALTER TABLE ${Collections._TABLE} ADD COLUMN ${Collections.SOURCE} TEXT NULL")
|
||||
db.execSQL("UPDATE ${Collections._TABLE} SET ${Collections.TYPE}=(" +
|
||||
"SELECT CASE ${Services.SERVICE} WHEN ? THEN ? ELSE ? END " +
|
||||
"FROM ${Services._TABLE} WHERE ${Services.ID}=${Collections._TABLE}.${Collections.SERVICE_ID}" +
|
||||
")",
|
||||
arrayOf(Services.SERVICE_CALDAV, CollectionInfo.Type.CALENDAR, CollectionInfo.Type.ADDRESS_BOOK))
|
||||
}
|
||||
|
||||
|
||||
fun dump(sb: StringBuilder) {
|
||||
val db = readableDatabase
|
||||
db.beginTransactionNonExclusive()
|
||||
|
||||
// iterate through all tables
|
||||
db.query("sqlite_master", arrayOf("name"), "type='table'", null, null, null, null).use { cursorTables ->
|
||||
while (cursorTables.moveToNext()) {
|
||||
val table = cursorTables.getString(0)
|
||||
sb.append(table).append("\n")
|
||||
db.query(table, null, null, null, null, null, null).use { cursor ->
|
||||
// print columns
|
||||
val cols = cursor.columnCount
|
||||
sb.append("\t| ")
|
||||
for (i in 0 until cols)
|
||||
sb .append(" ")
|
||||
.append(cursor.getColumnName(i))
|
||||
.append(" |")
|
||||
sb.append("\n")
|
||||
|
||||
// print rows
|
||||
while (cursor.moveToNext()) {
|
||||
sb.append("\t| ")
|
||||
for (i in 0 until cols) {
|
||||
sb.append(" ")
|
||||
try {
|
||||
val value = cursor.getString(i)
|
||||
if (value != null)
|
||||
sb.append(value
|
||||
.replace("\r", "<CR>")
|
||||
.replace("\n", "<LF>"))
|
||||
else
|
||||
sb.append("<null>")
|
||||
|
||||
} catch (e: SQLiteException) {
|
||||
sb.append("<unprintable>")
|
||||
}
|
||||
sb.append(" |")
|
||||
}
|
||||
sb.append("\n")
|
||||
}
|
||||
sb.append("----------\n")
|
||||
}
|
||||
}
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
35
app/src/main/java/at/bitfire/davdroid/model/ServiceDao.kt
Normal file
@@ -0,0 +1,35 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface ServiceDao {
|
||||
|
||||
@Query("SELECT * FROM service WHERE accountName=:accountName AND type=:type")
|
||||
fun getByAccountAndType(accountName: String, type: String): Service?
|
||||
|
||||
@Query("SELECT id FROM service WHERE accountName=:accountName AND type=:type")
|
||||
fun getIdByAccountAndType(accountName: String, type: String): Long?
|
||||
|
||||
@Query("SELECT * FROM service WHERE id=:id")
|
||||
fun get(id: Long): Service?
|
||||
|
||||
@Query("SELECT * FROM service WHERE type=:type")
|
||||
fun getByType(type: String): List<Service>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertOrReplace(service: Service): Long
|
||||
|
||||
@Query("DELETE FROM service")
|
||||
fun deleteAll()
|
||||
|
||||
@Query("DELETE FROM service WHERE accountName NOT IN (:accountNames)")
|
||||
fun deleteExceptAccounts(accountNames: Array<String>)
|
||||
|
||||
@Query("UPDATE service SET accountName=:newName WHERE accountName=:oldName")
|
||||
fun renameAccount(oldName: String, newName: String)
|
||||
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import at.bitfire.dav4android.property.SyncToken
|
||||
import at.bitfire.dav4jvm.property.SyncToken
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
|
||||
18
app/src/main/java/at/bitfire/davdroid/model/SyncableDao.kt
Normal file
@@ -0,0 +1,18 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Update
|
||||
|
||||
interface SyncableDao<T: IdEntity> {
|
||||
|
||||
@Insert
|
||||
fun insert(items: List<T>)
|
||||
|
||||
@Update
|
||||
fun update(item: T)
|
||||
|
||||
@Delete
|
||||
fun delete(item: T)
|
||||
|
||||
}
|
||||
@@ -9,7 +9,6 @@ package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.annotation.TargetApi
|
||||
import android.content.*
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -22,7 +21,7 @@ import android.util.Base64
|
||||
import at.bitfire.davdroid.DavUtils
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.vcard4android.*
|
||||
import java.io.ByteArrayOutputStream
|
||||
@@ -31,9 +30,9 @@ 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, DAVdroid creates a "DAVdroid
|
||||
* 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
|
||||
* DAVdroid main account.
|
||||
* DAVx5 main account.
|
||||
*/
|
||||
class LocalAddressBook(
|
||||
private val context: Context,
|
||||
@@ -48,14 +47,37 @@ class LocalAddressBook(
|
||||
const val USER_DATA_URL = "url"
|
||||
const val USER_DATA_READ_ONLY = "read_only"
|
||||
|
||||
fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, info: CollectionInfo): LocalAddressBook {
|
||||
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
|
||||
}
|
||||
|
||||
fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, info: Collection): LocalAddressBook {
|
||||
val accountManager = AccountManager.get(context)
|
||||
|
||||
val account = Account(accountName(mainAccount, info), context.getString(R.string.account_type_address_book))
|
||||
if (!accountManager.addAccountExplicitly(account, null, initialUserData(mainAccount, info.url.toString())))
|
||||
val userData = initialUserData(mainAccount, info.url.toString())
|
||||
Logger.log.log(Level.INFO, "Creating local address book $account", userData)
|
||||
if (!accountManager.addAccountExplicitly(account, null, userData))
|
||||
throw IllegalStateException("Couldn't create address book account")
|
||||
|
||||
if (!verifyUserData(context, account, userData))
|
||||
// Android seems to lose the initial user data sometimes, so set it a second time if that happens
|
||||
// https://forums.bitfire.at/post/11644
|
||||
userData.keySet().forEach { key ->
|
||||
accountManager.setUserData(account, key, userData.getString(key))
|
||||
}
|
||||
|
||||
val addressBook = LocalAddressBook(context, account, provider)
|
||||
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1)
|
||||
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
|
||||
|
||||
// initialize Contacts Provider Settings
|
||||
@@ -63,22 +85,34 @@ class LocalAddressBook(
|
||||
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)
|
||||
fun findAll(context: Context, provider: ContentProviderClient?, mainAccount: Account) = AccountManager.get(context)
|
||||
.getAccountsByType(context.getString(R.string.account_type_address_book))
|
||||
.map { LocalAddressBook(context, it, provider) }
|
||||
.filter { mainAccount == null || it.mainAccount == mainAccount }
|
||||
.filter {
|
||||
try {
|
||||
it.mainAccount == mainAccount
|
||||
} catch(e: IllegalStateException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
|
||||
fun accountName(mainAccount: Account, info: CollectionInfo): String {
|
||||
fun accountName(mainAccount: Account, info: Collection): String {
|
||||
val baos = ByteArrayOutputStream()
|
||||
baos.write(info.url.hashCode())
|
||||
val hash = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)
|
||||
|
||||
val sb = StringBuilder(if (info.displayName.isNullOrEmpty()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
|
||||
val sb = StringBuilder(info.displayName.let {
|
||||
if (it.isNullOrEmpty())
|
||||
DavUtils.lastSegmentOfUrl(info.url)
|
||||
else
|
||||
it
|
||||
})
|
||||
sb.append(" (${mainAccount.name} $hash)")
|
||||
return sb.toString()
|
||||
}
|
||||
@@ -103,6 +137,9 @@ class LocalAddressBook(
|
||||
|
||||
}
|
||||
|
||||
override val tag: String
|
||||
get() = "contacts-${account.name}"
|
||||
|
||||
override val title = account.name!!
|
||||
|
||||
/**
|
||||
@@ -115,6 +152,10 @@ class LocalAddressBook(
|
||||
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 }
|
||||
@@ -125,7 +166,7 @@ class LocalAddressBook(
|
||||
if (name != null && type != null)
|
||||
return Account(name, type)
|
||||
else
|
||||
throw IllegalStateException("Address book doesn't exist anymore")
|
||||
throw IllegalStateException("No main account assigned to address book account")
|
||||
}
|
||||
}
|
||||
set(newMainAccount) {
|
||||
@@ -142,7 +183,7 @@ class LocalAddressBook(
|
||||
?: throw IllegalStateException("Address book has no URL")
|
||||
set(url) = AccountManager.get(context).setUserData(account, USER_DATA_URL, url)
|
||||
|
||||
var readOnly: Boolean
|
||||
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)
|
||||
|
||||
@@ -163,7 +204,7 @@ class LocalAddressBook(
|
||||
if (includeGroups) {
|
||||
values.clear()
|
||||
values.put(LocalGroup.COLUMN_FLAGS, flags)
|
||||
number += provider.update(groupsSyncUri(), values, "${Groups.DIRTY}=0", null)
|
||||
number += provider.update(groupsSyncUri(), values, "NOT ${Groups.DIRTY}", null)
|
||||
}
|
||||
|
||||
return number
|
||||
@@ -171,19 +212,18 @@ class LocalAddressBook(
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int): Int {
|
||||
var number = provider!!.delete(rawContactsSyncUri(),
|
||||
"${RawContacts.DIRTY}=0 AND ${LocalContact.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
|
||||
"NOT ${RawContacts.DIRTY} AND ${LocalContact.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
|
||||
|
||||
if (includeGroups)
|
||||
number += provider.delete(groupsSyncUri(),
|
||||
"${Groups.DIRTY}=0 AND ${LocalGroup.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
|
||||
"NOT ${Groups.DIRTY} AND ${LocalGroup.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
|
||||
|
||||
return number
|
||||
}
|
||||
|
||||
fun update(info: CollectionInfo) {
|
||||
fun update(info: Collection) {
|
||||
val newAccountName = accountName(mainAccount, info)
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
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)
|
||||
@@ -191,15 +231,34 @@ class LocalAddressBook(
|
||||
account = future.result
|
||||
}
|
||||
|
||||
Constants.log.info("Address book read-only? = ${info.readOnly}")
|
||||
readOnly = info.readOnly || info.forceReadOnly
|
||||
val nowReadOnly = !info.privWriteContent || info.forceReadOnly
|
||||
if (nowReadOnly != readOnly) {
|
||||
Constants.log.info("Address book now read-only = $nowReadOnly, updating contacts")
|
||||
|
||||
// update address book itself
|
||||
readOnly = nowReadOnly
|
||||
|
||||
// update raw contacts
|
||||
val rawContactValues = ContentValues(1)
|
||||
rawContactValues.put(RawContacts.RAW_CONTACT_IS_READ_ONLY, if (nowReadOnly) 1 else 0)
|
||||
provider!!.update(rawContactsSyncUri(), rawContactValues, null, null)
|
||||
|
||||
// update data rows
|
||||
val dataValues = ContentValues(1)
|
||||
dataValues.put(ContactsContract.Data.IS_READ_ONLY, if (nowReadOnly) 1 else 0)
|
||||
provider.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null)
|
||||
}
|
||||
|
||||
// make sure it will still be synchronized when contacts are updated
|
||||
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
|
||||
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)
|
||||
}
|
||||
|
||||
fun delete() {
|
||||
val accountManager = AccountManager.get(context)
|
||||
@Suppress("DEPRECATION")
|
||||
if (Build.VERSION.SDK_INT >= 22)
|
||||
accountManager.removeAccount(account, null, null, null)
|
||||
else
|
||||
@@ -228,8 +287,8 @@ class LocalAddressBook(
|
||||
else
|
||||
findDeletedContacts()
|
||||
|
||||
fun findDeletedContacts() = queryContacts("${RawContacts.DELETED}!=0", null)
|
||||
fun findDeletedGroups() = queryGroups("${Groups.DELETED}!=0", null)
|
||||
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).
|
||||
@@ -240,16 +299,30 @@ class LocalAddressBook(
|
||||
findDirtyContacts() + findDirtyGroups()
|
||||
else
|
||||
findDirtyContacts()
|
||||
fun findDirtyContacts() = queryContacts(RawContacts.DIRTY, null)
|
||||
fun findDirtyGroups() = queryGroups(Groups.DIRTY, null)
|
||||
|
||||
fun findDirtyContacts() = queryContacts("${RawContacts.DIRTY}!=0", null)
|
||||
fun findDirtyGroups() = queryGroups("${Groups.DIRTY}!=0", null)
|
||||
override fun findDirtyWithoutNameOrUid() =
|
||||
if (includeGroups)
|
||||
findDirtyContactsWithoutNameOrUid() + findDirtyGroupsWithoutNameOrUid()
|
||||
else
|
||||
findDirtyContactsWithoutNameOrUid()
|
||||
private fun findDirtyContactsWithoutNameOrUid() = queryContacts(
|
||||
"${RawContacts.DIRTY} AND (${AndroidContact.COLUMN_FILENAME} IS NULL OR ${AndroidContact.COLUMN_UID} IS NULL)",
|
||||
null)
|
||||
private fun findDirtyGroupsWithoutNameOrUid() = queryGroups(
|
||||
"${Groups.DIRTY} AND (${AndroidGroup.COLUMN_FILENAME} IS NULL OR ${AndroidGroup.COLUMN_UID} IS NULL)",
|
||||
null)
|
||||
|
||||
private fun queryContactsGroups(whereContacts: String?, whereArgsContacts: Array<String>?, whereGroups: String?, whereArgsGroups: Array<String>?): List<LocalAddress> {
|
||||
val contacts = queryContacts(whereContacts, whereArgsContacts)
|
||||
return if (includeGroups)
|
||||
contacts + queryGroups(whereGroups, whereArgsGroups)
|
||||
else
|
||||
contacts
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -318,7 +391,7 @@ class LocalAddressBook(
|
||||
|
||||
val values = ContentValues(1)
|
||||
values.put(Groups.TITLE, title)
|
||||
val uri = provider.insert(syncAdapterURI(Groups.CONTENT_URI), values)
|
||||
val uri = provider.insert(syncAdapterURI(Groups.CONTENT_URI), values) ?: throw RemoteException("Couldn't create contact group")
|
||||
return ContentUris.parseId(uri)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,12 +14,12 @@ import android.content.ContentProviderOperation
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.*
|
||||
import android.provider.CalendarContract.Calendars
|
||||
import android.provider.CalendarContract.Events
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.DavUtils
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.AndroidCalendarFactory
|
||||
@@ -38,12 +38,15 @@ class LocalCalendar private constructor(
|
||||
|
||||
private const val COLUMN_SYNC_STATE = Calendars.CAL_SYNC1
|
||||
|
||||
fun create(account: Account, provider: ContentProviderClient, info: CollectionInfo): Uri {
|
||||
fun create(account: Account, provider: ContentProviderClient, info: Collection): Uri {
|
||||
val values = valuesFromCollectionInfo(info, true)
|
||||
|
||||
// ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash.
|
||||
values.put(Calendars.ACCOUNT_NAME, account.name)
|
||||
values.put(Calendars.ACCOUNT_TYPE, account.type)
|
||||
|
||||
// Email address for scheduling. Used by the calendar provider to determine whether the
|
||||
// user is ORGANIZER/ATTENDEE for a certain event.
|
||||
values.put(Calendars.OWNER_ACCOUNT, account.name)
|
||||
|
||||
// flag as visible & synchronizable at creation, might be changed by user at any time
|
||||
@@ -52,7 +55,7 @@ class LocalCalendar private constructor(
|
||||
return create(account, provider, values)
|
||||
}
|
||||
|
||||
private fun valuesFromCollectionInfo(info: CollectionInfo, withColor: Boolean): ContentValues {
|
||||
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
|
||||
val values = ContentValues()
|
||||
values.put(Calendars.NAME, info.url.toString())
|
||||
values.put(Calendars.CALENDAR_DISPLAY_NAME, if (info.displayName.isNullOrBlank()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
|
||||
@@ -60,15 +63,14 @@ class LocalCalendar private constructor(
|
||||
if (withColor)
|
||||
values.put(Calendars.CALENDAR_COLOR, info.color ?: Constants.DAVDROID_GREEN_RGBA)
|
||||
|
||||
if (info.readOnly || info.forceReadOnly)
|
||||
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ)
|
||||
else {
|
||||
if (info.privWriteContent && !info.forceReadOnly) {
|
||||
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER)
|
||||
values.put(Calendars.CAN_MODIFY_TIME_ZONE, 1)
|
||||
values.put(Calendars.CAN_ORGANIZER_RESPOND, 1)
|
||||
}
|
||||
} else
|
||||
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ)
|
||||
|
||||
info.timeZone?.let { tzData ->
|
||||
info.timezone?.let { tzData ->
|
||||
try {
|
||||
val timeZone = DateUtils.parseVTimeZone(tzData)
|
||||
timeZone.timeZoneId?.let { tzId ->
|
||||
@@ -78,18 +80,22 @@ class LocalCalendar private constructor(
|
||||
Logger.log.log(Level.WARNING, "Couldn't parse calendar default time zone", e)
|
||||
}
|
||||
}
|
||||
values.put(Calendars.ALLOWED_REMINDERS, "${Reminders.METHOD_ALERT},${Reminders.METHOD_EMAIL}")
|
||||
values.put(Calendars.ALLOWED_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}")
|
||||
|
||||
// add base values for Calendars
|
||||
values.putAll(calendarBaseValues)
|
||||
|
||||
return values
|
||||
}
|
||||
}
|
||||
|
||||
override val tag: String
|
||||
get() = "events-${account.name}-$id"
|
||||
|
||||
override val title: String
|
||||
get() = displayName ?: id.toString()
|
||||
|
||||
override var lastSyncState: SyncState?
|
||||
get() = provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.let { cursor ->
|
||||
get() = provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return SyncState.fromString(cursor.getString(0))
|
||||
else
|
||||
@@ -102,30 +108,48 @@ class LocalCalendar private constructor(
|
||||
}
|
||||
|
||||
|
||||
fun update(info: CollectionInfo, updateColor: Boolean) =
|
||||
fun update(info: Collection, updateColor: Boolean) =
|
||||
update(valuesFromCollectionInfo(info, updateColor))
|
||||
|
||||
|
||||
override fun findDeleted() =
|
||||
queryEvents("${Events.DELETED}!=0 AND ${Events.ORIGINAL_ID} IS NULL", null)
|
||||
queryEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
|
||||
|
||||
override fun findDirty(): List<LocalEvent> {
|
||||
val dirty = LinkedList<LocalEvent>()
|
||||
|
||||
// get dirty events which are required to have an increased SEQUENCE value
|
||||
for (localEvent in queryEvents("${Events.DIRTY}!=0 AND ${Events.ORIGINAL_ID} IS NULL", null)) {
|
||||
val event = localEvent.event!!
|
||||
val sequence = event.sequence
|
||||
if (event.sequence == null) // sequence has not been assigned yet (i.e. this event was just locally created)
|
||||
event.sequence = 0
|
||||
else if (localEvent.weAreOrganizer)
|
||||
event.sequence = sequence!! + 1
|
||||
/*
|
||||
* RFC 5545 3.8.7.4. Sequence Number
|
||||
* When a calendar component is created, its sequence number is 0. It is monotonically incremented by the "Organizer's"
|
||||
* CUA each time the "Organizer" makes a significant revision to the calendar component.
|
||||
*/
|
||||
for (localEvent in queryEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null)) {
|
||||
try {
|
||||
val event = requireNotNull(localEvent.event)
|
||||
|
||||
val nonGroupScheduled = event.attendees.isEmpty()
|
||||
val weAreOrganizer = localEvent.weAreOrganizer
|
||||
|
||||
val sequence = event.sequence
|
||||
if (sequence == null)
|
||||
// sequence has not been assigned yet (i.e. this event was just locally created)
|
||||
event.sequence = 0
|
||||
else if (nonGroupScheduled || weAreOrganizer) // increase sequence
|
||||
event.sequence = sequence + 1
|
||||
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't check/increase sequence", e)
|
||||
}
|
||||
dirty += localEvent
|
||||
}
|
||||
|
||||
return dirty
|
||||
}
|
||||
|
||||
override fun findDirtyWithoutNameOrUid() =
|
||||
queryEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL AND " +
|
||||
"(${Events._SYNC_ID} IS NULL OR ${Events.UID_2445} IS NULL)", null)
|
||||
|
||||
override fun findByName(name: String) =
|
||||
queryEvents("${Events._SYNC_ID}=?", arrayOf(name)).firstOrNull()
|
||||
|
||||
@@ -134,14 +158,36 @@ class LocalCalendar private constructor(
|
||||
val values = ContentValues(1)
|
||||
values.put(LocalEvent.COLUMN_FLAGS, flags)
|
||||
return provider.update(eventsSyncURI(), values,
|
||||
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY}=0 AND ${Events.ORIGINAL_ID} IS NULL",
|
||||
"${Events.CALENDAR_ID}=? AND NOT ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int) =
|
||||
provider.delete(eventsSyncURI(),
|
||||
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY}=0 AND ${Events.ORIGINAL_ID} IS NULL AND ${LocalEvent.COLUMN_FLAGS}=?",
|
||||
arrayOf(id.toString(), flags.toString()))
|
||||
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.Operation(
|
||||
ContentProviderOperation.newDelete(eventsSyncURI())
|
||||
.withSelection("${Events._ID}=? OR ${Events.ORIGINAL_ID}=?", arrayOf(id.toString(), id.toString()))
|
||||
))
|
||||
}
|
||||
deleted = batch.commit()
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
|
||||
override fun forgetETags() {
|
||||
val values = ContentValues(1)
|
||||
values.putNull(LocalEvent.COLUMN_ETAG)
|
||||
provider.update(eventsSyncURI(), values, "${Events.CALENDAR_ID}=?",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
|
||||
fun processDirtyExceptions() {
|
||||
@@ -150,7 +196,7 @@ class LocalCalendar private constructor(
|
||||
provider.query(
|
||||
syncAdapterURI(Events.CONTENT_URI),
|
||||
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
|
||||
"${Events.CALENDAR_ID}=? AND ${Events.DELETED}!=0 AND ${Events.ORIGINAL_ID} IS NOT NULL",
|
||||
"${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)")
|
||||
@@ -190,7 +236,7 @@ class LocalCalendar private constructor(
|
||||
provider.query(
|
||||
syncAdapterURI(Events.CONTENT_URI),
|
||||
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
|
||||
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY}!=0 AND ${Events.ORIGINAL_ID} IS NOT NULL",
|
||||
"${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")
|
||||
|
||||
@@ -8,31 +8,81 @@
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.provider.CalendarContract.Events
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
|
||||
interface LocalCollection<out T: LocalResource<*>> {
|
||||
|
||||
/** a tag that uniquely identifies the collection (DAVx5-wide) */
|
||||
val tag: String
|
||||
|
||||
/** collection title (used for user notifications etc.) **/
|
||||
val title: String
|
||||
|
||||
var lastSyncState: SyncState?
|
||||
|
||||
/**
|
||||
* 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>
|
||||
|
||||
/**
|
||||
* 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>
|
||||
|
||||
/**
|
||||
* Finds local resources of this collection which do not have a file name and/or UID, but
|
||||
* need one for synchronization.
|
||||
*
|
||||
* For instance, exceptions of recurring events are local resources but do not need their
|
||||
* own file name/UID because they're sent with the same UID as the main event.
|
||||
*
|
||||
* @return list of resources which need file name and UID for synchronization, but don't have both of them
|
||||
*/
|
||||
fun findDirtyWithoutNameOrUid(): List<T>
|
||||
|
||||
/**
|
||||
* 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?
|
||||
|
||||
|
||||
/**
|
||||
* Marks all entries which are not dirty with the given flags only.
|
||||
* @return number of marked entries
|
||||
**/
|
||||
* 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 all entries with are not dirty and are marked with exactly the given flags.
|
||||
* @return number of removed entries
|
||||
* 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()
|
||||
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
|
||||
companion object {
|
||||
init {
|
||||
Contact.productID = "+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ez-vcard/" + Ezvcard.VERSION
|
||||
Contact.productID = "+//IDN bitfire.at//${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ez-vcard/" + Ezvcard.VERSION
|
||||
}
|
||||
|
||||
const val COLUMN_FLAGS = ContactsContract.RawContacts.SYNC4
|
||||
@@ -37,6 +37,10 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
private val cachedGroupMemberships = HashSet<Long>()
|
||||
private val groupMemberships = HashSet<Long>()
|
||||
|
||||
override var scheduleTag: String?
|
||||
get() = null
|
||||
set(value) = throw NotImplementedError()
|
||||
|
||||
override var flags: Int = 0
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<LocalContact,*>, values: ContentValues)
|
||||
@@ -50,20 +54,34 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
}
|
||||
|
||||
|
||||
override fun assignNameAndUID() {
|
||||
val uid = UUID.randomUUID().toString()
|
||||
val newFileName = "$uid.vcf"
|
||||
override fun prepareForFirstUpload(): String {
|
||||
var uid: String? = null
|
||||
addressBook.provider!!.query(rawContactSyncURI(), arrayOf(COLUMN_UID), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
uid = cursor.getString(0)
|
||||
}
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(COLUMN_FILENAME, newFileName)
|
||||
values.put(COLUMN_UID, uid)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
if (uid == null) {
|
||||
// generate new UID
|
||||
uid = UUID.randomUUID().toString()
|
||||
|
||||
fileName = newFileName
|
||||
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(eTag: String?) {
|
||||
val values = ContentValues(3)
|
||||
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)
|
||||
|
||||
@@ -76,6 +94,8 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
if (fileName != null)
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
}
|
||||
|
||||
@@ -93,7 +113,7 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
val values = ContentValues(1)
|
||||
values.put(LocalContact.COLUMN_FLAGS, flags)
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
this.flags = flags
|
||||
@@ -214,7 +234,7 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
|
||||
/**
|
||||
* Returns the IDs of all groups the contact was member of (cached memberships).
|
||||
* Cached memberships are kept in sync with memberships by DAVdroid and are used to determine
|
||||
* Cached memberships are kept in sync with memberships by DAVx5 and are used to determine
|
||||
* whether a membership has been deleted/added when a raw contact is dirty.
|
||||
* @return set of {@link GroupMembership#GROUP_ROW_ID} (may be empty)
|
||||
* @throws FileNotFoundException if the current contact can't be found
|
||||
|
||||
@@ -10,7 +10,6 @@ package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentValues
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.Events
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.ical4android.*
|
||||
@@ -21,39 +20,43 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
|
||||
|
||||
companion object {
|
||||
init {
|
||||
ICalendar.prodId = ProdId("+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ical4j/" + Constants.ical4jVersion)
|
||||
ICalendar.prodId = ProdId("${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ical4j/" + Ical4Android.ical4jVersion)
|
||||
}
|
||||
|
||||
const val COLUMN_ETAG = CalendarContract.Events.SYNC_DATA1
|
||||
const val COLUMN_FLAGS = CalendarContract.Events.SYNC_DATA2
|
||||
const val COLUMN_SEQUENCE = CalendarContract.Events.SYNC_DATA3
|
||||
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
|
||||
}
|
||||
|
||||
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 = true
|
||||
var weAreOrganizer = false
|
||||
private set
|
||||
|
||||
|
||||
constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?, flags: Int): super(calendar, event) {
|
||||
constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int): super(calendar, event) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.scheduleTag = scheduleTag
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
private constructor(calendar: AndroidCalendar<*>, values: ContentValues): super(calendar, values) {
|
||||
fileName = values.getAsString(Events._SYNC_ID)
|
||||
eTag = values.getAsString(COLUMN_ETAG)
|
||||
scheduleTag = values.getAsString(COLUMN_SCHEDULE_TAG)
|
||||
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
|
||||
}
|
||||
|
||||
override fun populateEvent(row: ContentValues) {
|
||||
super.populateEvent(row)
|
||||
override fun populateEvent(row: ContentValues, groupScheduled: Boolean) {
|
||||
val event = requireNotNull(event)
|
||||
|
||||
event.uid = row.getAsString(Events.UID_2445)
|
||||
@@ -61,6 +64,8 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
|
||||
|
||||
val isOrganizer = row.getAsInteger(Events.IS_ORGANIZER)
|
||||
weAreOrganizer = isOrganizer != null && isOrganizer != 0
|
||||
|
||||
super.populateEvent(row, groupScheduled)
|
||||
}
|
||||
|
||||
override fun buildEvent(recurrence: Event?, builder: ContentProviderOperation.Builder) {
|
||||
@@ -72,46 +77,54 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
|
||||
|
||||
builder .withValue(Events.UID_2445, event.uid)
|
||||
.withValue(COLUMN_SEQUENCE, eventToBuild.sequence)
|
||||
.withValue(CalendarContract.Events.DIRTY, 0)
|
||||
.withValue(CalendarContract.Events.DELETED, 0)
|
||||
.withValue(LocalEvent.COLUMN_FLAGS, flags)
|
||||
.withValue(Events.DIRTY, 0)
|
||||
.withValue(Events.DELETED, 0)
|
||||
.withValue(COLUMN_FLAGS, flags)
|
||||
|
||||
if (buildException)
|
||||
builder .withValue(Events.ORIGINAL_SYNC_ID, fileName)
|
||||
else
|
||||
builder .withValue(Events._SYNC_ID, fileName)
|
||||
.withValue(COLUMN_ETAG, eTag)
|
||||
.withValue(COLUMN_SCHEDULE_TAG, scheduleTag)
|
||||
}
|
||||
|
||||
|
||||
override fun assignNameAndUID() {
|
||||
override fun prepareForFirstUpload(): String {
|
||||
var uid: String? = null
|
||||
calendar.provider.query(eventSyncURI(), arrayOf(Events.UID_2445), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
uid = cursor.getString(0)
|
||||
}
|
||||
if (uid == null)
|
||||
|
||||
if (uid == null) {
|
||||
// generate new UID
|
||||
uid = UUID.randomUUID().toString()
|
||||
|
||||
val newFileName = "$uid.ics"
|
||||
val values = ContentValues(1)
|
||||
values.put(Events.UID_2445, uid)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(Events._SYNC_ID, newFileName)
|
||||
values.put(Events.UID_2445, uid)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
event!!.uid = uid
|
||||
}
|
||||
|
||||
fileName = newFileName
|
||||
event!!.uid = uid
|
||||
return "$uid.ics"
|
||||
}
|
||||
|
||||
override fun clearDirty(eTag: String?) {
|
||||
val values = ContentValues(2)
|
||||
values.put(CalendarContract.Events.DIRTY, 0)
|
||||
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
|
||||
val values = ContentValues(5)
|
||||
if (fileName != null)
|
||||
values.put(Events._SYNC_ID, fileName)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(COLUMN_SCHEDULE_TAG, scheduleTag)
|
||||
values.put(COLUMN_SEQUENCE, event!!.sequence)
|
||||
values.put(Events.DIRTY, 0)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
if (fileName != null)
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.scheduleTag = scheduleTag
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
|
||||
@@ -20,7 +20,6 @@ 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 java.util.*
|
||||
|
||||
@@ -101,6 +100,10 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
||||
}
|
||||
|
||||
|
||||
override var scheduleTag: String?
|
||||
get() = null
|
||||
set(value) = throw NotImplementedError()
|
||||
|
||||
override var flags: Int = 0
|
||||
|
||||
|
||||
@@ -130,27 +133,43 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
||||
}
|
||||
|
||||
|
||||
override fun assignNameAndUID() {
|
||||
val uid = UUID.randomUUID().toString()
|
||||
val newFileName = "$uid.vcf"
|
||||
override fun prepareForFirstUpload(): String {
|
||||
var uid: String? = null
|
||||
addressBook.provider!!.query(groupSyncUri(), arrayOf(AndroidContact.COLUMN_UID), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
uid = cursor.getString(0)
|
||||
}
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(COLUMN_FILENAME, newFileName)
|
||||
values.put(COLUMN_UID, uid)
|
||||
update(values)
|
||||
if (uid == null) {
|
||||
// generate new UID
|
||||
uid = UUID.randomUUID().toString()
|
||||
|
||||
fileName = newFileName
|
||||
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(eTag: String?) {
|
||||
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(2)
|
||||
values.put(Groups.DIRTY, 0)
|
||||
val values = ContentValues(3)
|
||||
if (fileName != null)
|
||||
values.put(COLUMN_FILENAME, fileName)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
this.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!!)
|
||||
|
||||
@@ -200,7 +219,7 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
val values = ContentValues(1)
|
||||
values.put(LocalGroup.COLUMN_FLAGS, flags)
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
addressBook.provider!!.update(groupSyncUri(), values, null, null)
|
||||
|
||||
this.flags = flags
|
||||
|
||||
@@ -28,14 +28,44 @@ interface LocalResource<in TData: Any> {
|
||||
*/
|
||||
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?
|
||||
var eTag: String?
|
||||
var scheduleTag: String?
|
||||
val flags: Int
|
||||
|
||||
fun assignNameAndUID()
|
||||
fun clearDirty(eTag: String?)
|
||||
/**
|
||||
* Prepares the resource for the first upload.
|
||||
*
|
||||
* 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 should be used for the upload is derived from the UID and returned, but not
|
||||
* saved to the content provider.
|
||||
*
|
||||
* @return new file name of the resource (like "<uid>.vcf")
|
||||
*/
|
||||
fun prepareForFirstUpload(): 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)
|
||||
|
||||
@@ -10,10 +10,8 @@ package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentValues
|
||||
import at.bitfire.ical4android.AndroidTask
|
||||
import at.bitfire.ical4android.AndroidTaskFactory
|
||||
import at.bitfire.ical4android.AndroidTaskList
|
||||
import at.bitfire.ical4android.Task
|
||||
import android.provider.CalendarContract
|
||||
import at.bitfire.ical4android.*
|
||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||
import java.util.*
|
||||
|
||||
@@ -25,6 +23,8 @@ class LocalTask: AndroidTask, LocalResource<Task> {
|
||||
}
|
||||
|
||||
override var fileName: String? = null
|
||||
|
||||
override var scheduleTag: String? = null
|
||||
override var eTag: String? = null
|
||||
|
||||
override var flags = 0
|
||||
@@ -50,7 +50,6 @@ class LocalTask: AndroidTask, LocalResource<Task> {
|
||||
|
||||
override fun buildTask(builder: ContentProviderOperation.Builder, update: Boolean) {
|
||||
super.buildTask(builder, update)
|
||||
val task = requireNotNull(task)
|
||||
|
||||
builder .withValue(Tasks._SYNC_ID, fileName)
|
||||
.withValue(COLUMN_ETAG, eTag)
|
||||
@@ -60,27 +59,41 @@ class LocalTask: AndroidTask, LocalResource<Task> {
|
||||
|
||||
/* custom queries */
|
||||
|
||||
override fun assignNameAndUID() {
|
||||
val uid = UUID.randomUUID().toString()
|
||||
val newFileName = "$uid.ics"
|
||||
override fun prepareForFirstUpload(): 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)
|
||||
}
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(Tasks._SYNC_ID, newFileName)
|
||||
values.put(Tasks._UID, uid)
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null)
|
||||
if (uid == null) {
|
||||
// generate new UID
|
||||
uid = UUID.randomUUID().toString()
|
||||
|
||||
fileName = newFileName
|
||||
val values = ContentValues(1)
|
||||
values.put(Tasks._UID, uid)
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null)
|
||||
|
||||
task!!.uid = uid
|
||||
task!!.uid = uid
|
||||
}
|
||||
|
||||
return "$uid.ics"
|
||||
}
|
||||
|
||||
override fun clearDirty(eTag: String?) {
|
||||
val values = ContentValues(3)
|
||||
values.put(Tasks._DIRTY, 0)
|
||||
override fun clearDirty(fileName: String?, eTag: String?, scheduleTag: String?) {
|
||||
if (scheduleTag != null)
|
||||
Ical4Android.log.fine("Schedule-Tag for tasks not supported yet, won't save")
|
||||
|
||||
val values = ContentValues(4)
|
||||
if (fileName != null)
|
||||
values.put(Tasks._SYNC_ID, fileName)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(Tasks.SYNC_VERSION, task!!.sequence)
|
||||
values.put(Tasks._DIRTY, 0)
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null)
|
||||
|
||||
if (fileName != null)
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
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
|
||||
@@ -17,8 +18,9 @@ import android.net.Uri
|
||||
import android.os.Build
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.DavUtils
|
||||
import at.bitfire.davdroid.closeCompat
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.ical4android.AndroidTaskList
|
||||
import at.bitfire.ical4android.AndroidTaskListFactory
|
||||
@@ -49,7 +51,7 @@ class LocalTaskList private constructor(
|
||||
return false
|
||||
}
|
||||
|
||||
fun create(account: Account, provider: TaskProvider, info: CollectionInfo): Uri {
|
||||
fun create(account: Account, provider: TaskProvider, info: Collection): Uri {
|
||||
val values = valuesFromCollectionInfo(info, true)
|
||||
values.put(TaskLists.OWNER, account.name)
|
||||
values.put(TaskLists.SYNC_ENABLED, 1)
|
||||
@@ -57,6 +59,7 @@ class LocalTaskList private constructor(
|
||||
return create(account, provider, values)
|
||||
}
|
||||
|
||||
@SuppressLint("Recycle")
|
||||
@Throws(Exception::class)
|
||||
fun onRenameAccount(resolver: ContentResolver, oldName: String, newName: String) {
|
||||
var client: ContentProviderClient? = null
|
||||
@@ -68,14 +71,11 @@ class LocalTaskList private constructor(
|
||||
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()
|
||||
client?.closeCompat()
|
||||
}
|
||||
}
|
||||
|
||||
private fun valuesFromCollectionInfo(info: CollectionInfo, withColor: Boolean): ContentValues {
|
||||
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
|
||||
val values = ContentValues(3)
|
||||
values.put(TaskLists._SYNC_ID, info.url.toString())
|
||||
values.put(TaskLists.LIST_NAME, if (info.displayName.isNullOrBlank()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
|
||||
@@ -88,6 +88,9 @@ class LocalTaskList private constructor(
|
||||
|
||||
}
|
||||
|
||||
override val tag: String
|
||||
get() = "tasks-${account.name}-$id"
|
||||
|
||||
override val title: String
|
||||
get() = name ?: id.toString()
|
||||
|
||||
@@ -113,25 +116,32 @@ class LocalTaskList private constructor(
|
||||
}
|
||||
|
||||
|
||||
fun update(info: CollectionInfo, updateColor: Boolean) =
|
||||
fun update(info: Collection, updateColor: Boolean) =
|
||||
update(valuesFromCollectionInfo(info, updateColor))
|
||||
|
||||
|
||||
override fun findDeleted() = queryTasks("${Tasks._DELETED}!=0", null)
|
||||
override fun findDeleted() = queryTasks(Tasks._DELETED, null)
|
||||
|
||||
override fun findDirty(): List<LocalTask> {
|
||||
val tasks = queryTasks("${Tasks._DIRTY}!=0", null)
|
||||
val tasks = queryTasks(Tasks._DIRTY, null)
|
||||
for (localTask in tasks) {
|
||||
val task = requireNotNull(localTask.task)
|
||||
val sequence = task.sequence
|
||||
if (sequence == null) // sequence has not been assigned yet (i.e. this task was just locally created)
|
||||
task.sequence = 0
|
||||
else
|
||||
task.sequence = sequence + 1
|
||||
try {
|
||||
val task = requireNotNull(localTask.task)
|
||||
val sequence = task.sequence
|
||||
if (sequence == null) // sequence has not been assigned yet (i.e. this task was just locally created)
|
||||
task.sequence = 0
|
||||
else // task was modified, increase sequence
|
||||
task.sequence = sequence + 1
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't check/increase sequence", e)
|
||||
}
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
override fun findDirtyWithoutNameOrUid() =
|
||||
queryTasks("${Tasks._DIRTY} AND (${Tasks._SYNC_ID} IS NULL OR ${Tasks._UID} IS NULL)", null)
|
||||
|
||||
override fun findByName(name: String) =
|
||||
queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name)).firstOrNull()
|
||||
|
||||
@@ -146,9 +156,16 @@ class LocalTaskList private constructor(
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int) =
|
||||
provider.client.delete(tasksSyncUri(),
|
||||
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0 AND ${LocalTask.COLUMN_FLAGS}=?",
|
||||
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${LocalTask.COLUMN_FLAGS}=?",
|
||||
arrayOf(id.toString(), flags.toString()))
|
||||
|
||||
override fun forgetETags() {
|
||||
val values = ContentValues(1)
|
||||
values.putNull(LocalEvent.COLUMN_ETAG)
|
||||
provider.client.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
|
||||
object Factory: AndroidTaskListFactory<LocalTaskList> {
|
||||
|
||||
|
||||
@@ -5,39 +5,38 @@
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.*
|
||||
import android.os.Build
|
||||
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.ContactsContract
|
||||
import androidx.core.content.ContextCompat
|
||||
import at.bitfire.davdroid.*
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.model.Credentials
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.ServiceDB.*
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.davdroid.model.Service
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalCalendar
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import at.bitfire.davdroid.resource.LocalTask
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.AndroidTaskList
|
||||
import at.bitfire.ical4android.CalendarStorageException
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.ical4android.TaskProvider.ProviderName.OpenTasks
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.dmfs.tasks.contract.TaskContract
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
@@ -45,15 +44,15 @@ import java.util.logging.Level
|
||||
*
|
||||
* @throws InvalidAccountException on construction when the account doesn't exist (anymore)
|
||||
*/
|
||||
@Suppress("FunctionName")
|
||||
class AccountSettings(
|
||||
val context: Context,
|
||||
val settings: ISettings,
|
||||
val account: Account
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
const val CURRENT_VERSION = 8
|
||||
const val CURRENT_VERSION = 10
|
||||
const val KEY_SETTINGS_VERSION = "version"
|
||||
|
||||
const val KEY_USERNAME = "user_name"
|
||||
@@ -62,20 +61,28 @@ class AccountSettings(
|
||||
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]
|
||||
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
|
||||
/** 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 DAVdroid sets the local calendar color to the value from service DB at every sync
|
||||
/**
|
||||
* 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 DAVdroid populates and uses CalendarContract.Colors
|
||||
/* Whether DAVx5 populates and uses CalendarContract.Colors
|
||||
value = null (not existing) false (default)
|
||||
"1" true */
|
||||
const val KEY_EVENT_COLORS = "event_colors"
|
||||
@@ -103,9 +110,10 @@ class AccountSettings(
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
val accountManager: AccountManager = AccountManager.get(context)
|
||||
val settings = SettingsManager.getInstance(context)
|
||||
|
||||
init {
|
||||
synchronized(AccountSettings::class.java) {
|
||||
@@ -159,15 +167,15 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun getSyncWifiOnly() = if (settings.has(KEY_WIFI_ONLY))
|
||||
settings.getBoolean(KEY_WIFI_ONLY, false)
|
||||
fun getSyncWifiOnly() = if (settings.containsKey(KEY_WIFI_ONLY))
|
||||
settings.getBoolean(KEY_WIFI_ONLY)
|
||||
else
|
||||
accountManager.getUserData(account, KEY_WIFI_ONLY) != null
|
||||
fun setSyncWiFiOnly(wiFiOnly: Boolean) =
|
||||
accountManager.setUserData(account, KEY_WIFI_ONLY, if (wiFiOnly) "1" else null)
|
||||
|
||||
fun getSyncWifiOnlySSIDs(): List<String>? = (if (settings.has(KEY_WIFI_ONLY_SSIDS))
|
||||
settings.getString(KEY_WIFI_ONLY_SSIDS, null)
|
||||
fun getSyncWifiOnlySSIDs(): List<String>? = (if (settings.containsKey(KEY_WIFI_ONLY_SSIDS))
|
||||
settings.getString(KEY_WIFI_ONLY_SSIDS)
|
||||
else
|
||||
accountManager.getUserData(account, KEY_WIFI_ONLY_SSIDS))?.split(',')
|
||||
fun setSyncWifiOnlySSIDs(ssids: List<String>?) =
|
||||
@@ -179,8 +187,11 @@ class AccountSettings(
|
||||
fun getTimeRangePastDays(): Int? {
|
||||
val strDays = accountManager.getUserData(account, KEY_TIME_RANGE_PAST_DAYS)
|
||||
return if (strDays != null) {
|
||||
val days = Integer.valueOf(strDays)
|
||||
if (days < 0) null else days
|
||||
val days = strDays.toInt()
|
||||
if (days < 0)
|
||||
null
|
||||
else
|
||||
days
|
||||
} else
|
||||
DEFAULT_TIME_RANGE_PAST_DAYS
|
||||
}
|
||||
@@ -188,15 +199,44 @@ class AccountSettings(
|
||||
fun setTimeRangePastDays(days: Int?) =
|
||||
accountManager.setUserData(account, KEY_TIME_RANGE_PAST_DAYS, (days ?: -1).toString())
|
||||
|
||||
fun getManageCalendarColors() = if (settings.has(KEY_MANAGE_CALENDAR_COLORS))
|
||||
settings.getBoolean(KEY_MANAGE_CALENDAR_COLORS, false)
|
||||
/**
|
||||
* 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.has(KEY_EVENT_COLORS))
|
||||
settings.getBoolean(KEY_EVENT_COLORS, false)
|
||||
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) =
|
||||
@@ -205,7 +245,7 @@ class AccountSettings(
|
||||
// CardDAV settings
|
||||
|
||||
fun getGroupMethod(): GroupMethod {
|
||||
val name = settings.getString(KEY_CONTACT_GROUP_METHOD, null) ?:
|
||||
val name = settings.getString(KEY_CONTACT_GROUP_METHOD) ?:
|
||||
accountManager.getUserData(account, KEY_CONTACT_GROUP_METHOD)
|
||||
if (name != null)
|
||||
try {
|
||||
@@ -224,7 +264,7 @@ class AccountSettings(
|
||||
// update from previous account settings
|
||||
|
||||
private fun update(baseVersion: Int) {
|
||||
for (toVersion in baseVersion+1 .. CURRENT_VERSION) {
|
||||
for (toVersion in baseVersion+1 ..CURRENT_VERSION) {
|
||||
val fromVersion = toVersion-1
|
||||
Logger.log.info("Updating account ${account.name} from version $fromVersion to $toVersion")
|
||||
try {
|
||||
@@ -239,19 +279,59 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
|
||||
@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, TaskProvider.ProviderName.OpenTasks)?.let { provider ->
|
||||
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 ->
|
||||
arrayOf(account.type, account.name), null)!!.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(0)
|
||||
val eTag = cursor.getString(1)
|
||||
@@ -271,16 +351,14 @@ class AccountSettings(
|
||||
}
|
||||
|
||||
@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 {
|
||||
if (Build.VERSION.SDK_INT >= 24)
|
||||
provider.close()
|
||||
else
|
||||
provider.release()
|
||||
provider.closeCompat()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +369,7 @@ class AccountSettings(
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@SuppressLint("ParcelClassLoader")
|
||||
@SuppressLint("Recycle", "ParcelClassLoader")
|
||||
private fun update_5_6() {
|
||||
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { provider ->
|
||||
val parcel = Parcel.obtain()
|
||||
@@ -308,15 +386,13 @@ class AccountSettings(
|
||||
else {
|
||||
parcel.unmarshall(raw, 0, raw.size)
|
||||
parcel.setDataPosition(0)
|
||||
val params = parcel.readBundle()
|
||||
val url = params.getString("url")?.let { HttpUrl.parse(it) }
|
||||
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 = CollectionInfo(url)
|
||||
info.type = CollectionInfo.Type.ADDRESS_BOOK
|
||||
info.displayName = account.name
|
||||
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())))
|
||||
@@ -343,10 +419,7 @@ class AccountSettings(
|
||||
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()
|
||||
provider.closeCompat()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,7 +435,7 @@ class AccountSettings(
|
||||
@Suppress("unused")
|
||||
private fun update_4_5() {
|
||||
// call PackageChangedReceiver which then enables/disables OpenTasks sync when it's (not) available
|
||||
PackageChangedReceiver.updateTaskSync(context)
|
||||
OpenTasksWatcher.updateTaskSync(context)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@@ -370,171 +443,6 @@ class AccountSettings(
|
||||
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.url
|
||||
Logger.log.fine("Migrating address book $url")
|
||||
|
||||
// insert CardDAV service
|
||||
val values = ContentValues(3)
|
||||
values.put(Services.ACCOUNT_NAME, account.name)
|
||||
values.put(Services.SERVICE, Services.SERVICE_CARDDAV)
|
||||
serviceCardDAV = db.insert(Services._TABLE, null, values)
|
||||
|
||||
// insert address book
|
||||
values.clear()
|
||||
values.put(Collections.SERVICE_ID, serviceCardDAV)
|
||||
values.put(Collections.URL, url)
|
||||
values.put(Collections.SYNC, 1)
|
||||
db.insert(Collections._TABLE, null, values)
|
||||
|
||||
// insert home set
|
||||
HttpUrl.parse(url)?.let {
|
||||
val homeSet = it.resolve("../")
|
||||
values.clear()
|
||||
values.put(HomeSets.SERVICE_ID, serviceCardDAV)
|
||||
values.put(HomeSets.URL, homeSet.toString())
|
||||
db.insert(HomeSets._TABLE, null, values)
|
||||
}
|
||||
} catch (e: ContactsStorageException) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't migrate address book", e)
|
||||
} finally {
|
||||
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)?.use { provider ->
|
||||
try {
|
||||
val taskLists = AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null)
|
||||
for (taskList in taskLists)
|
||||
taskList.syncId?.let { url ->
|
||||
Logger.log.fine("Migrating task list $url")
|
||||
collections.add(url)
|
||||
HttpUrl.parse(url)?.resolve("../")?.let { homeSets.add(it) }
|
||||
}
|
||||
} catch (e: CalendarStorageException) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't migrate task lists", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (!collections.isEmpty()) {
|
||||
// insert CalDAV service
|
||||
val values = ContentValues(3)
|
||||
values.put(Services.ACCOUNT_NAME, account.name)
|
||||
values.put(Services.SERVICE, Services.SERVICE_CALDAV)
|
||||
serviceCalDAV = db.insert(Services._TABLE, null, values)
|
||||
|
||||
// insert collections
|
||||
for (url in collections) {
|
||||
values.clear()
|
||||
values.put(Collections.SERVICE_ID, serviceCalDAV)
|
||||
values.put(Collections.URL, url)
|
||||
values.put(Collections.SYNC, 1)
|
||||
db.insert(Collections._TABLE, null, values)
|
||||
}
|
||||
|
||||
// insert home sets
|
||||
for (homeSet in homeSets) {
|
||||
values.clear()
|
||||
values.put(HomeSets.SERVICE_ID, serviceCalDAV)
|
||||
values.put(HomeSets.URL, homeSet.toString())
|
||||
db.insert(HomeSets._TABLE, null, values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// initiate service detection (refresh) to get display names, colors etc.
|
||||
val refresh = Intent(context, DavService::class.java)
|
||||
refresh.action = DavService.ACTION_REFRESH_COLLECTIONS
|
||||
serviceCardDAV?.let {
|
||||
refresh.putExtra(DavService.EXTRA_DAV_SERVICE_ID, it)
|
||||
context.startService(refresh)
|
||||
}
|
||||
serviceCalDAV?.let {
|
||||
refresh.putExtra(DavService.EXTRA_DAV_SERVICE_ID, it)
|
||||
context.startService(refresh)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@SuppressLint("Recycle")
|
||||
private fun update_1_2() {
|
||||
/* - KEY_ADDRESSBOOK_URL ("addressbook_url"),
|
||||
- KEY_ADDRESSBOOK_CTAG ("addressbook_ctag"),
|
||||
- KEY_ADDRESSBOOK_VCARD_VERSION ("addressbook_vcard_version") are not used anymore (now stored in ContactsContract.SyncState)
|
||||
- KEY_LAST_ANDROID_VERSION ("last_android_version") has been added
|
||||
*/
|
||||
|
||||
// move previous address book 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.settings = values
|
||||
|
||||
val url = accountManager.getUserData(account, "addressbook_url")
|
||||
if (!url.isNullOrEmpty())
|
||||
addr.url = url
|
||||
accountManager.setUserData(account, "addressbook_url", null)
|
||||
|
||||
val cTag = accountManager.getUserData (account, "addressbook_ctag")
|
||||
if (!cTag.isNullOrEmpty())
|
||||
addr.lastSyncState = SyncState(SyncState.Type.CTAG, cTag)
|
||||
accountManager.setUserData(account, "addressbook_ctag", null)
|
||||
} finally {
|
||||
if (Build.VERSION.SDK_INT >= 24)
|
||||
provider.close()
|
||||
else
|
||||
@Suppress("deprecation")
|
||||
provider.release()
|
||||
}
|
||||
}
|
||||
// updates from AccountSettings version 2 and below are not supported anymore
|
||||
|
||||
}
|
||||
@@ -8,67 +8,99 @@
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import at.bitfire.davdroid.App
|
||||
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
|
||||
|
||||
open class DefaultsProvider(
|
||||
private val allowOverride: Boolean = true
|
||||
): Provider {
|
||||
val context: Context,
|
||||
val settingsManager: SettingsManager
|
||||
): SettingsProvider {
|
||||
|
||||
open val booleanDefaults = mapOf(
|
||||
Pair(App.DISTRUST_SYSTEM_CERTIFICATES, false),
|
||||
Pair(App.OVERRIDE_PROXY, false)
|
||||
open val booleanDefaults = mutableMapOf(
|
||||
Pair(Settings.DISTRUST_SYSTEM_CERTIFICATES, false),
|
||||
Pair(Settings.OVERRIDE_PROXY, false)
|
||||
)
|
||||
|
||||
open val intDefaults = mapOf(
|
||||
Pair(App.OVERRIDE_PROXY_PORT, App.OVERRIDE_PROXY_PORT_DEFAULT)
|
||||
Pair(Settings.OVERRIDE_PROXY_PORT, 8118)
|
||||
)
|
||||
|
||||
open val longDefaults = mapOf<String, Long>()
|
||||
|
||||
open val stringDefaults = mapOf(
|
||||
Pair(App.OVERRIDE_PROXY_HOST, App.OVERRIDE_PROXY_HOST_DEFAULT)
|
||||
Pair(Settings.OVERRIDE_PROXY_HOST, "localhost")
|
||||
)
|
||||
|
||||
val dataSaverChangedListener by lazy {
|
||||
object: BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
evaluateDataSaver(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private fun hasKey(key: String) =
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
override fun canWrite() = false
|
||||
|
||||
|
||||
override fun contains(key: String) =
|
||||
booleanDefaults.containsKey(key) ||
|
||||
intDefaults.containsKey(key) ||
|
||||
longDefaults.containsKey(key) ||
|
||||
stringDefaults.containsKey(key)
|
||||
|
||||
override fun has(key: String): Pair<Boolean, Boolean> {
|
||||
val has = hasKey(key)
|
||||
return Pair(has, allowOverride || !has)
|
||||
|
||||
override fun getBoolean(key: String) = 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()
|
||||
|
||||
|
||||
class Factory : SettingsProviderFactory {
|
||||
override fun getProviders(context: Context, settingsManager: SettingsManager) =
|
||||
listOf(DefaultsProvider(context, settingsManager))
|
||||
}
|
||||
|
||||
|
||||
override fun getBoolean(key: String) =
|
||||
Pair(booleanDefaults[key], allowOverride || !booleanDefaults.containsKey(key))
|
||||
|
||||
override fun getInt(key: String) =
|
||||
Pair(intDefaults[key], allowOverride || !intDefaults.containsKey(key))
|
||||
|
||||
override fun getLong(key: String) =
|
||||
Pair(longDefaults[key], allowOverride || !longDefaults.containsKey(key))
|
||||
|
||||
override fun getString(key: String) =
|
||||
Pair(stringDefaults[key], allowOverride || !stringDefaults.containsKey(key))
|
||||
|
||||
|
||||
override fun isWritable(key: String) = Pair(false, allowOverride || !hasKey(key))
|
||||
|
||||
override fun putBoolean(key: String, value: Boolean?) = false
|
||||
override fun putInt(key: String, value: Int?) = false
|
||||
override fun putLong(key: String, value: Long?) = false
|
||||
override fun putString(key: String, value: String?) = false
|
||||
|
||||
override fun remove(key: String) = false
|
||||
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import java.io.Closeable
|
||||
|
||||
interface Provider: Closeable {
|
||||
|
||||
fun forceReload()
|
||||
|
||||
fun has(key: String): Pair<Boolean, Boolean>
|
||||
|
||||
fun getBoolean(key: String): Pair<Boolean?, Boolean>
|
||||
fun getInt(key: String): Pair<Int?, Boolean>
|
||||
fun getLong(key: String): Pair<Long?, Boolean>
|
||||
fun getString(key: String): Pair<String?, Boolean>
|
||||
|
||||
fun isWritable(key: String): Pair<Boolean, Boolean>
|
||||
|
||||
fun putBoolean(key: String, value: Boolean?): Boolean
|
||||
fun putInt(key: String, value: Int?): Boolean
|
||||
fun putLong(key: String, value: Long?): Boolean
|
||||
fun putString(key: String, value: String?): Boolean
|
||||
|
||||
fun remove(key: String): Boolean
|
||||
|
||||
|
||||
interface Observer {
|
||||
fun onReload()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,289 +1,11 @@
|
||||
/*
|
||||
* 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.annotation.TargetApi
|
||||
import android.app.Service
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
object Settings {
|
||||
|
||||
class Settings: Service(), Provider.Observer {
|
||||
const val DISTRUST_SYSTEM_CERTIFICATES = "distrust_system_certs"
|
||||
|
||||
private val providers = LinkedList<Provider>()
|
||||
private val observers = LinkedList<WeakReference<ISettingsObserver>>()
|
||||
const val OVERRIDE_PROXY = "override_proxy"
|
||||
const val OVERRIDE_PROXY_HOST = "override_proxy_host"
|
||||
const val OVERRIDE_PROXY_PORT = "override_proxy_port"
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
override fun onCreate() {
|
||||
Logger.log.info("Initializing Settings service")
|
||||
|
||||
// always add a defaults provider first
|
||||
providers.add(DefaultsProvider())
|
||||
|
||||
// load flavor-specific providers
|
||||
val providersLoader = ServiceLoader.load(ISettingsProviderFactory::class.java)!!
|
||||
providersLoader.forEach { factory ->
|
||||
factory.getProviders(this).forEach {
|
||||
Logger.log.info("Registering settings provider: ${it.javaClass.name}")
|
||||
providers.add(it)
|
||||
}
|
||||
}
|
||||
|
||||
// always add a provider for local preferences
|
||||
providers.add(SharedPreferencesProvider(this))
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Logger.log.info("Shutting down Settings service")
|
||||
providers.forEach { it.close() }
|
||||
providers.clear()
|
||||
}
|
||||
|
||||
override fun onTrimMemory(level: Int) {
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
|
||||
fun forceReload() {
|
||||
providers.forEach { it.forceReload() }
|
||||
}
|
||||
|
||||
override fun onReload() {
|
||||
observers.forEach {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
it.get()?.onSettingsChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun has(key: String): Boolean {
|
||||
Logger.log.fine("Looking for setting $key")
|
||||
var result = false
|
||||
for (provider in providers)
|
||||
try {
|
||||
val (value, further) = provider.has(key)
|
||||
Logger.log.finer("${provider::class.java.simpleName}: has $key = $value, continue: $further")
|
||||
if (value) {
|
||||
result = true
|
||||
break
|
||||
}
|
||||
if (!further)
|
||||
break
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't look up setting in $provider", e)
|
||||
}
|
||||
Logger.log.fine("Looking for setting $key -> $result")
|
||||
return result
|
||||
}
|
||||
|
||||
private fun<T> getValue(key: String, reader: (Provider) -> Pair<T?, Boolean>): T? {
|
||||
Logger.log.fine("Looking up setting $key")
|
||||
var result: T? = null
|
||||
for (provider in providers)
|
||||
try {
|
||||
val (value, further) = reader(provider)
|
||||
Logger.log.finer("${provider::class.java.simpleName}: value = $value, continue: $further")
|
||||
value?.let { result = it }
|
||||
if (!further)
|
||||
break
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't read setting from $provider", e)
|
||||
}
|
||||
Logger.log.fine("Looked up setting $key -> $result")
|
||||
return result
|
||||
}
|
||||
|
||||
fun getBoolean(key: String) =
|
||||
getValue(key) { provider -> provider.getBoolean(key) }
|
||||
|
||||
fun getInt(key: String) =
|
||||
getValue(key) { provider -> provider.getInt(key) }
|
||||
|
||||
fun getLong(key: String) =
|
||||
getValue(key) { provider -> provider.getLong(key) }
|
||||
|
||||
fun getString(key: String) =
|
||||
getValue(key) { provider -> provider.getString(key) }
|
||||
|
||||
|
||||
fun isWritable(key: String): Boolean {
|
||||
for (provider in providers) {
|
||||
val (value, further) = provider.isWritable(key)
|
||||
if (value)
|
||||
return true
|
||||
if (!further)
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun<T> putValue(key: String, value: T?, writer: (Provider) -> Boolean): Boolean {
|
||||
Logger.log.fine("Trying to write setting $key = $value")
|
||||
for (provider in providers) {
|
||||
val (writable, further) = provider.isWritable(key)
|
||||
Logger.log.finer("${provider::class.java.simpleName}: writable = $writable, continue: $further")
|
||||
if (writable)
|
||||
return try {
|
||||
writer(provider)
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't write setting to $provider", e)
|
||||
false
|
||||
}
|
||||
if (!further)
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun putBoolean(key: String, value: Boolean?) =
|
||||
putValue(key, value) { provider -> provider.putBoolean(key, value) }
|
||||
|
||||
fun putInt(key: String, value: Int?) =
|
||||
putValue(key, value) { provider -> provider.putInt(key, value) }
|
||||
|
||||
fun putLong(key: String, value: Long?) =
|
||||
putValue(key, value) { provider -> provider.putLong(key, value) }
|
||||
|
||||
fun putString(key: String, value: String?) =
|
||||
putValue(key, value) { provider -> provider.putString(key, value) }
|
||||
|
||||
fun remove(key: String): Boolean {
|
||||
var deleted = false
|
||||
providers.forEach { deleted = deleted || it.remove(key) }
|
||||
return deleted
|
||||
}
|
||||
|
||||
|
||||
val binder = object: ISettings.Stub() {
|
||||
|
||||
override fun forceReload() =
|
||||
this@Settings.forceReload()
|
||||
|
||||
override fun has(key: String) =
|
||||
this@Settings.has(key)
|
||||
|
||||
override fun getBoolean(key: String, defaultValue: Boolean) =
|
||||
this@Settings.getBoolean(key) ?: defaultValue
|
||||
|
||||
override fun getInt(key: String, defaultValue: Int) =
|
||||
this@Settings.getInt(key) ?: defaultValue
|
||||
|
||||
override fun getLong(key: String, defaultValue: Long) =
|
||||
this@Settings.getLong(key) ?: defaultValue
|
||||
|
||||
override fun getString(key: String, defaultValue: String?) =
|
||||
this@Settings.getString(key) ?: defaultValue
|
||||
|
||||
override fun isWritable(key: String) =
|
||||
this@Settings.isWritable(key)
|
||||
|
||||
override fun remove(key: String) =
|
||||
this@Settings.remove(key)
|
||||
|
||||
override fun putBoolean(key: String, value: Boolean) =
|
||||
this@Settings.putBoolean(key, value)
|
||||
|
||||
override fun putString(key: String, value: String?) =
|
||||
this@Settings.putString(key, value)
|
||||
|
||||
override fun putInt(key: String, value: Int) =
|
||||
this@Settings.putInt(key, value)
|
||||
|
||||
override fun putLong(key: String, value: Long) =
|
||||
this@Settings.putLong(key, value)
|
||||
|
||||
override fun registerObserver(observer: ISettingsObserver) {
|
||||
observers += WeakReference(observer)
|
||||
}
|
||||
|
||||
override fun unregisterObserver(observer: ISettingsObserver) {
|
||||
observers.removeAll { it.get() == observer }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?) = binder
|
||||
|
||||
|
||||
class Stub(
|
||||
delegate: ISettings,
|
||||
private val context: Context,
|
||||
private val serviceConn: ServiceConnection?
|
||||
): ISettings by delegate, AutoCloseable {
|
||||
|
||||
override fun close() {
|
||||
serviceConn?.let {
|
||||
try {
|
||||
context.unbindService(it)
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't unbind Settings service", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun getInstance(context: Context): Stub? {
|
||||
if (context is Settings)
|
||||
return Stub(context.binder, context, null)
|
||||
|
||||
if (Looper.getMainLooper().thread == Thread.currentThread())
|
||||
throw IllegalStateException("Must not be called from main thread")
|
||||
|
||||
var service: ISettings? = null
|
||||
val serviceLock = Object()
|
||||
val serviceConn = object: ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName, binder: IBinder) {
|
||||
synchronized(serviceLock) {
|
||||
service = ISettings.Stub.asInterface(binder)
|
||||
serviceLock.notify()
|
||||
}
|
||||
}
|
||||
override fun onServiceDisconnected(name: ComponentName) {
|
||||
service = null
|
||||
}
|
||||
}
|
||||
|
||||
if (!context.bindService(Intent(context, Settings::class.java), serviceConn, Context.BIND_AUTO_CREATE or Context.BIND_IMPORTANT))
|
||||
return null
|
||||
|
||||
synchronized(serviceLock) {
|
||||
if (service == null)
|
||||
try {
|
||||
serviceLock.wait()
|
||||
} catch(e: InterruptedException) {
|
||||
}
|
||||
|
||||
if (service == null) {
|
||||
try {
|
||||
context.unbindService(serviceConn)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return Stub(service!!, context, serviceConn)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* 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.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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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")
|
||||
var result: T? = null
|
||||
for (provider in providers)
|
||||
try {
|
||||
val value = reader(provider)
|
||||
Logger.log.finer("${provider::class.java.simpleName}: value = $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() == true)
|
||||
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)
|
||||
|
||||
|
||||
interface OnChangeListener {
|
||||
/**
|
||||
* Will be called when something has changed in a [SettingsProvider].
|
||||
* May run in worker thread!
|
||||
*/
|
||||
@AnyThread
|
||||
fun onSettingsChanged()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* 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()
|
||||
|
||||
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)
|
||||
|
||||
}
|
||||
@@ -8,8 +8,10 @@
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
interface ISettingsProviderFactory {
|
||||
import android.content.Context
|
||||
|
||||
fun getProviders(settings: Settings): List<Provider>
|
||||
interface SettingsProviderFactory {
|
||||
|
||||
fun getProviders(context: Context, settingsManager: SettingsManager): Iterable<SettingsProvider>
|
||||
|
||||
}
|
||||
@@ -11,13 +11,15 @@ package at.bitfire.davdroid.settings
|
||||
import android.content.Context
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import android.content.SharedPreferences
|
||||
import android.preference.PreferenceManager
|
||||
import android.util.NoSuchPropertyException
|
||||
import androidx.preference.PreferenceManager
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
|
||||
class SharedPreferencesProvider(
|
||||
context: Context
|
||||
): Provider {
|
||||
val context: Context,
|
||||
val settingsManager: SettingsManager
|
||||
): SettingsProvider, SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
companion object {
|
||||
private const val META_VERSION = "version"
|
||||
@@ -34,52 +36,57 @@ class SharedPreferencesProvider(
|
||||
firstCall(context)
|
||||
meta.edit().putInt(META_VERSION, CURRENT_VERSION).apply()
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
preferences.registerOnSharedPreferenceChangeListener(this)
|
||||
}
|
||||
|
||||
override fun forceReload() {
|
||||
}
|
||||
|
||||
|
||||
override fun has(key: String) =
|
||||
Pair(preferences.contains(key), true)
|
||||
|
||||
private fun<T> getValue(key: String, reader: (SharedPreferences) -> T): Pair<T?, Boolean> {
|
||||
if (preferences.contains(key))
|
||||
return Pair(
|
||||
try { reader(preferences) } catch(e: ClassCastException) { null },
|
||||
true)
|
||||
|
||||
return Pair(null, true)
|
||||
override fun close() {
|
||||
preferences.unregisterOnSharedPreferenceChangeListener(this)
|
||||
}
|
||||
|
||||
override fun getBoolean(key: String): Pair<Boolean?, Boolean> =
|
||||
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): Pair<Int?, Boolean> =
|
||||
override fun getInt(key: String) =
|
||||
getValue(key) { preferences -> preferences.getInt(key, /* will never be used: */ -1) }
|
||||
|
||||
override fun getLong(key: String): Pair<Long?, Boolean> =
|
||||
override fun getLong(key: String) =
|
||||
getValue(key) { preferences -> preferences.getLong(key, /* will never be used: */ -1) }
|
||||
|
||||
override fun getString(key: String): Pair<String?, Boolean> =
|
||||
getValue(key) { preferences -> preferences.getString(key, /* will never be used: */ null) }
|
||||
override fun getString(key: String): String? =
|
||||
preferences.getString(key, /* will never be used: */ null)
|
||||
|
||||
|
||||
override fun isWritable(key: String) =
|
||||
Pair(true, true)
|
||||
|
||||
private fun<T> putValue(key: String, value: T?, writer: (SharedPreferences.Editor, T) -> Unit): Boolean {
|
||||
return if (value == null)
|
||||
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()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,12 +102,9 @@ class SharedPreferencesProvider(
|
||||
override fun putString(key: String, value: String?) =
|
||||
putValue(key, value) { editor, v -> editor.putString(key, v) }
|
||||
|
||||
override fun remove(key: String): Boolean {
|
||||
override fun remove(key: String) {
|
||||
Logger.log.fine("Removing setting $key")
|
||||
preferences.edit()
|
||||
.remove(key)
|
||||
.apply()
|
||||
return true
|
||||
preferences.edit().remove(key).apply()
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +118,12 @@ class SharedPreferencesProvider(
|
||||
edit.apply()
|
||||
|
||||
// open ServiceDB to upgrade it and possibly migrate settings
|
||||
ServiceDB.OpenHelper(context).use { it.readableDatabase }
|
||||
AppDatabase.getInstance(context)
|
||||
}
|
||||
|
||||
|
||||
class Factory : SettingsProviderFactory {
|
||||
override fun getProviders(context: Context, settingsManager: SettingsManager) = listOf(SharedPreferencesProvider(context, settingsManager))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -11,59 +11,57 @@ import android.accounts.*
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.database.DatabaseUtils
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.WorkerThread
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.ui.setup.LoginActivity
|
||||
import java.util.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.logging.Level
|
||||
|
||||
|
||||
/**
|
||||
* Account authenticator for the main DAVdroid account type.
|
||||
* Account authenticator for the main DAVx5 account type.
|
||||
*
|
||||
* Gets started when a DAVdroid account is removed, too, so it also watches for account removals
|
||||
* Gets started when a DAVx5 account is removed, too, so it also watches for account removals
|
||||
* and contains the corresponding cleanup code.
|
||||
*/
|
||||
class AccountAuthenticatorService: Service(), OnAccountsUpdateListener {
|
||||
|
||||
companion object {
|
||||
|
||||
@WorkerThread
|
||||
@Synchronized
|
||||
fun cleanupAccounts(context: Context) {
|
||||
Logger.log.info("Cleaning up orphaned accounts")
|
||||
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.writableDatabase
|
||||
val accountManager = AccountManager.get(context)
|
||||
val accountNames = accountManager.getAccountsByType(context.getString(R.string.account_type))
|
||||
.map { it.name }
|
||||
|
||||
val sqlAccountNames = LinkedList<String>()
|
||||
val accountNames = HashSet<String>()
|
||||
val accountManager = AccountManager.get(context)
|
||||
for (account in accountManager.getAccountsByType(context.getString(R.string.account_type))) {
|
||||
sqlAccountNames.add(DatabaseUtils.sqlEscapeString(account.name))
|
||||
accountNames += account.name
|
||||
}
|
||||
|
||||
// delete orphaned address book accounts
|
||||
accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
|
||||
.map { LocalAddressBook(context, it, null) }
|
||||
.forEach {
|
||||
try {
|
||||
if (!accountNames.contains(it.mainAccount.name))
|
||||
it.delete()
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't delete address book account", e)
|
||||
}
|
||||
// delete orphaned address book accounts
|
||||
accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
|
||||
.map { LocalAddressBook(context, it, null) }
|
||||
.forEach {
|
||||
try {
|
||||
if (!accountNames.contains(it.mainAccount.name))
|
||||
it.delete()
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't delete address book account", e)
|
||||
}
|
||||
}
|
||||
|
||||
// delete orphaned services in DB
|
||||
if (sqlAccountNames.isEmpty())
|
||||
db.delete(ServiceDB.Services._TABLE, null, null)
|
||||
else
|
||||
db.delete(ServiceDB.Services._TABLE, "${ServiceDB.Services.ACCOUNT_NAME} NOT IN (${sqlAccountNames.joinToString(",")})", null)
|
||||
}
|
||||
// delete orphaned services in DB
|
||||
val db = AppDatabase.getInstance(context)
|
||||
val serviceDao = db.serviceDao()
|
||||
if (accountNames.isEmpty())
|
||||
serviceDao.deleteAll()
|
||||
else
|
||||
serviceDao.deleteExceptAccounts(accountNames.toTypedArray())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -85,11 +83,13 @@ class AccountAuthenticatorService: Service(), OnAccountsUpdateListener {
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?) =
|
||||
accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT }
|
||||
accountAuthenticator.iBinder.takeIf { intent?.action == AccountManager.ACTION_AUTHENTICATOR_INTENT }
|
||||
|
||||
|
||||
override fun onAccountsUpdated(accounts: Array<out Account>?) {
|
||||
cleanupAccounts(this)
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
cleanupAccounts(this@AccountAuthenticatorService)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -12,13 +12,14 @@ import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
|
||||
@Suppress("ImplicitNullableNothingType")
|
||||
class AddressBookProvider: ContentProvider() {
|
||||
|
||||
override fun onCreate() = false
|
||||
override fun insert(p0: Uri?, p1: ContentValues?) = null
|
||||
override fun query(p0: Uri?, p1: Array<out String>?, p2: String?, p3: Array<out String>?, p4: String?) = null
|
||||
override fun update(p0: Uri?, p1: ContentValues?, p2: String?, p3: Array<out String>?) = 0
|
||||
override fun delete(p0: Uri?, p1: String?, p2: Array<out String>?) = 0
|
||||
override fun getType(p0: Uri?) = null
|
||||
override fun insert(p0: Uri, p1: ContentValues?) = null
|
||||
override fun query(p0: Uri, p1: Array<out String>?, p2: String?, p3: Array<out String>?, p4: String?) = null
|
||||
override fun update(p0: Uri, p1: ContentValues?, p2: String?, p3: Array<out String>?) = 0
|
||||
override fun delete(p0: Uri, p1: String?, p2: Array<out String>?) = 0
|
||||
override fun getType(p0: Uri) = null
|
||||
|
||||
}
|
||||
|
||||
@@ -9,24 +9,23 @@ package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.*
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.content.pm.PackageManager
|
||||
import android.database.DatabaseUtils
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.ContactsContract
|
||||
import android.support.v4.content.ContextCompat
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.R
|
||||
import androidx.core.content.ContextCompat
|
||||
import at.bitfire.davdroid.closeCompat
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.model.Service
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import at.bitfire.davdroid.ui.AccountActivity
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import java.util.logging.Level
|
||||
|
||||
class AddressBooksSyncAdapterService : SyncAdapterService() {
|
||||
@@ -38,9 +37,9 @@ class AddressBooksSyncAdapterService : SyncAdapterService() {
|
||||
context: Context
|
||||
) : SyncAdapter(context) {
|
||||
|
||||
override fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
try {
|
||||
val accountSettings = AccountSettings(context, settings, account)
|
||||
val accountSettings = AccountSettings(context, account)
|
||||
|
||||
/* don't run sync if
|
||||
- sync conditions (e.g. "sync only in WiFi") are not met AND
|
||||
@@ -49,16 +48,14 @@ class AddressBooksSyncAdapterService : SyncAdapterService() {
|
||||
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings))
|
||||
return
|
||||
|
||||
updateLocalAddressBooks(provider, account, syncResult)
|
||||
|
||||
val accountManager = AccountManager.get(context)
|
||||
for (addressBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) {
|
||||
Logger.log.log(Level.INFO, "Running sync for address book", addressBookAccount)
|
||||
val syncExtras = Bundle(extras)
|
||||
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_SETTINGS, true)
|
||||
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, true)
|
||||
ContentResolver.requestSync(addressBookAccount, ContactsContract.AUTHORITY, syncExtras)
|
||||
}
|
||||
if (updateLocalAddressBooks(account, syncResult))
|
||||
for (addressBookAccount in LocalAddressBook.findAll(context, null, account).map { it.account }) {
|
||||
Logger.log.log(Level.INFO, "Running sync for address book", addressBookAccount)
|
||||
val syncExtras = Bundle(extras)
|
||||
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_SETTINGS, true)
|
||||
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, true)
|
||||
ContentResolver.requestSync(addressBookAccount, ContactsContract.AUTHORITY, syncExtras)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't sync address books", e)
|
||||
}
|
||||
@@ -66,94 +63,61 @@ class AddressBooksSyncAdapterService : SyncAdapterService() {
|
||||
Logger.log.info("Address book sync complete")
|
||||
}
|
||||
|
||||
private fun updateLocalAddressBooks(provider: ContentProviderClient, account: Account, syncResult: SyncResult) {
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.readableDatabase
|
||||
private fun updateLocalAddressBooks(account: Account, syncResult: SyncResult): Boolean {
|
||||
val db = AppDatabase.getInstance(context)
|
||||
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV)
|
||||
|
||||
fun getService() =
|
||||
db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID),
|
||||
"${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
|
||||
arrayOf(account.name, ServiceDB.Services.SERVICE_CARDDAV), null, null, null)?.use { c ->
|
||||
if (c.moveToNext())
|
||||
c.getLong(0)
|
||||
else
|
||||
null
|
||||
}
|
||||
val remoteAddressBooks = mutableMapOf<HttpUrl, Collection>()
|
||||
if (service != null)
|
||||
for (collection in db.collectionDao().getByServiceAndSync(service.id))
|
||||
remoteAddressBooks[collection.url] = collection
|
||||
|
||||
fun remoteAddressBooks(service: Long?): MutableMap<HttpUrl, CollectionInfo> {
|
||||
val collections = mutableMapOf<HttpUrl, CollectionInfo>()
|
||||
service?.let {
|
||||
db.query(Collections._TABLE, null,
|
||||
Collections.SERVICE_ID + "=? AND " + Collections.SYNC, arrayOf(service.toString()), null, null, null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val values = ContentValues(cursor.columnCount)
|
||||
DatabaseUtils.cursorRowToContentValues(cursor, values)
|
||||
val info = CollectionInfo(values)
|
||||
collections[info.url] = info
|
||||
}
|
||||
}
|
||||
}
|
||||
return collections
|
||||
}
|
||||
|
||||
// enumerate remote and local address books
|
||||
val service = getService()
|
||||
val remote = remoteAddressBooks(service)
|
||||
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
|
||||
if (remote.isEmpty()) {
|
||||
Logger.log.info("No contacts permission, but no address book selected for synchronization")
|
||||
return
|
||||
} else {
|
||||
// no contacts permission, but address books should be synchronized -> show notification
|
||||
val intent = Intent(context, AccountActivity::class.java)
|
||||
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
notifyPermissions(intent)
|
||||
}
|
||||
}
|
||||
|
||||
val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
|
||||
try {
|
||||
if (contactsProvider == null) {
|
||||
Logger.log.severe("Couldn't access contacts provider")
|
||||
syncResult.databaseError = true
|
||||
return
|
||||
}
|
||||
|
||||
// delete/update local address books
|
||||
for (addressBook in LocalAddressBook.findAll(context, contactsProvider, account)) {
|
||||
val url = HttpUrl.parse(addressBook.url)!!
|
||||
val info = remote[url]
|
||||
if (info == null) {
|
||||
Logger.log.log(Level.INFO, "Deleting obsolete local address book", url)
|
||||
addressBook.delete()
|
||||
} else {
|
||||
// remote CollectionInfo found for this local collection, update data
|
||||
try {
|
||||
Logger.log.log(Level.FINE, "Updating local address book $url", info)
|
||||
addressBook.update(info)
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't rename address book account", e)
|
||||
}
|
||||
// we already have a local address book for this remote collection, don't take into consideration anymore
|
||||
remote -= url
|
||||
}
|
||||
}
|
||||
|
||||
// create new local address books
|
||||
for ((_, info) in remote) {
|
||||
Logger.log.log(Level.INFO, "Adding local address book", info)
|
||||
LocalAddressBook.create(context, contactsProvider, account, info)
|
||||
}
|
||||
} finally {
|
||||
if (Build.VERSION.SDK_INT >= 24)
|
||||
contactsProvider?.close()
|
||||
else
|
||||
contactsProvider?.release()
|
||||
}
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
|
||||
if (remoteAddressBooks.isEmpty())
|
||||
Logger.log.info("No contacts permission, but no address book selected for synchronization")
|
||||
else
|
||||
Logger.log.warning("No contacts permission, but address books are selected for synchronization")
|
||||
return false
|
||||
}
|
||||
|
||||
val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
|
||||
try {
|
||||
if (contactsProvider == null) {
|
||||
Logger.log.severe("Couldn't access contacts provider")
|
||||
syncResult.databaseError = true
|
||||
return false
|
||||
}
|
||||
|
||||
// delete/update local address books
|
||||
for (addressBook in LocalAddressBook.findAll(context, contactsProvider, account)) {
|
||||
val url = addressBook.url.toHttpUrl()
|
||||
val info = remoteAddressBooks[url]
|
||||
if (info == null) {
|
||||
Logger.log.log(Level.INFO, "Deleting obsolete local address book", url)
|
||||
addressBook.delete()
|
||||
} else {
|
||||
// remote CollectionInfo found for this local collection, update data
|
||||
try {
|
||||
Logger.log.log(Level.FINE, "Updating local address book $url", info)
|
||||
addressBook.update(info)
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't rename address book account", e)
|
||||
}
|
||||
// we already have a local address book for this remote collection, don't take into consideration anymore
|
||||
remoteAddressBooks -= url
|
||||
}
|
||||
}
|
||||
|
||||
// create new local address books
|
||||
for ((_, info) in remoteAddressBooks) {
|
||||
Logger.log.log(Level.INFO, "Adding local address book", info)
|
||||
LocalAddressBook.create(context, contactsProvider, account, info)
|
||||
}
|
||||
} finally {
|
||||
contactsProvider?.closeCompat()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -12,27 +12,31 @@ import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import at.bitfire.dav4android.DavCalendar
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.DavResponseCallback
|
||||
import at.bitfire.dav4android.Response
|
||||
import at.bitfire.dav4android.exception.DavException
|
||||
import at.bitfire.dav4android.property.*
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.dav4jvm.DavCalendar
|
||||
import at.bitfire.dav4jvm.DavResponseCallback
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.property.*
|
||||
import at.bitfire.davdroid.DavUtils
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.davdroid.resource.LocalCalendar
|
||||
import at.bitfire.davdroid.resource.LocalEvent
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.ical4android.DateUtils
|
||||
import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.InvalidCalendarException
|
||||
import net.fortuna.ical4j.model.component.VAlarm
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.Reader
|
||||
import java.io.StringReader
|
||||
import java.time.Duration
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
@@ -41,17 +45,16 @@ import java.util.logging.Level
|
||||
*/
|
||||
class CalendarSyncManager(
|
||||
context: Context,
|
||||
settings: ISettings,
|
||||
account: Account,
|
||||
accountSettings: AccountSettings,
|
||||
extras: Bundle,
|
||||
authority: String,
|
||||
syncResult: SyncResult,
|
||||
localCalendar: LocalCalendar
|
||||
): SyncManager<LocalEvent, LocalCalendar, DavCalendar>(context, settings, account, accountSettings, extras, authority, syncResult, localCalendar) {
|
||||
): SyncManager<LocalEvent, LocalCalendar, DavCalendar>(context, account, accountSettings, extras, authority, syncResult, localCalendar) {
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
collectionURL = HttpUrl.parse(localCollection.name ?: return false) ?: return false
|
||||
collectionURL = (localCollection.name ?: return false).toHttpUrlOrNull() ?: return false
|
||||
davCollection = DavCalendar(httpClient.okHttpClient, collectionURL)
|
||||
|
||||
// if there are dirty exceptions for events, mark their master events as dirty, too
|
||||
@@ -61,14 +64,13 @@ class CalendarSyncManager(
|
||||
}
|
||||
|
||||
override fun queryCapabilities(): SyncState? =
|
||||
useRemoteCollection {
|
||||
remoteExceptionContext {
|
||||
var syncState: SyncState? = null
|
||||
it.propfind(0, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF) {
|
||||
response[SupportedReportSet::class.java]?.let {
|
||||
hasCollectionSync = it.reports.contains(SupportedReportSet.SYNC_COLLECTION)
|
||||
response[SupportedReportSet::class.java]?.let { supported ->
|
||||
hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION)
|
||||
}
|
||||
|
||||
syncState = syncState(response)
|
||||
}
|
||||
}
|
||||
@@ -82,17 +84,14 @@ class CalendarSyncManager(
|
||||
else
|
||||
SyncAlgorithm.COLLECTION_SYNC
|
||||
|
||||
override fun prepareUpload(resource: LocalEvent): RequestBody = useLocal(resource) {
|
||||
override fun generateUpload(resource: LocalEvent): RequestBody = localExceptionContext(resource) {
|
||||
val event = requireNotNull(resource.event)
|
||||
Logger.log.log(Level.FINE, "Preparing upload of event ${resource.fileName}", event)
|
||||
|
||||
val os = ByteArrayOutputStream()
|
||||
event.write(os)
|
||||
|
||||
RequestBody.create(
|
||||
DavCalendar.MIME_ICALENDAR_UTF8,
|
||||
os.toByteArray()
|
||||
)
|
||||
os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
|
||||
}
|
||||
|
||||
override fun listAllRemote(callback: DavResponseCallback) {
|
||||
@@ -104,7 +103,7 @@ class CalendarSyncManager(
|
||||
limitStart = calendar.time
|
||||
}
|
||||
|
||||
return useRemoteCollection { remote ->
|
||||
return remoteExceptionContext { remote ->
|
||||
Logger.log.info("Querying events since $limitStart")
|
||||
remote.calendarQuery("VEVENT", limitStart, null, callback)
|
||||
}
|
||||
@@ -112,36 +111,26 @@ class CalendarSyncManager(
|
||||
|
||||
override fun downloadRemote(bunch: List<HttpUrl>) {
|
||||
Logger.log.info("Downloading ${bunch.size} iCalendars: $bunch")
|
||||
if (bunch.size == 1) {
|
||||
val remote = bunch.first()
|
||||
// only one contact, use GET
|
||||
useRemote(DavResource(httpClient.okHttpClient, remote)) { resource ->
|
||||
resource.get(DavCalendar.MIME_ICALENDAR.toString()) { response ->
|
||||
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
|
||||
val eTag = response.header("ETag")?.let { GetETag(it).eTag }
|
||||
?: throw DavException("Received CalDAV GET response without ETag")
|
||||
|
||||
response.body()!!.use {
|
||||
processVEvent(resource.fileName(), eTag, it.charStream())
|
||||
}
|
||||
}
|
||||
}
|
||||
} else
|
||||
// multiple iCalendars, use calendar-multi-get
|
||||
useRemoteCollection {
|
||||
it.multiget(bunch) { response, _ ->
|
||||
useRemote(response) {
|
||||
val eTag = response[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
|
||||
val calendarData = response[CalendarData::class.java]
|
||||
val iCal = calendarData?.iCalendar
|
||||
?: throw DavException("Received multi-get response without address data")
|
||||
|
||||
processVEvent(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(iCal))
|
||||
remoteExceptionContext {
|
||||
it.multiget(bunch) { response, _ ->
|
||||
responseExceptionContext(response) {
|
||||
if (!response.isSuccess()) {
|
||||
Logger.log.warning("Received non-successful multiget response for ${response.href}")
|
||||
return@responseExceptionContext
|
||||
}
|
||||
|
||||
val eTag = response[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
val scheduleTag = response[ScheduleTag::class.java]?.scheduleTag
|
||||
|
||||
val calendarData = response[CalendarData::class.java]
|
||||
val iCal = calendarData?.iCalendar
|
||||
?: throw DavException("Received multi-get response without address data")
|
||||
|
||||
processVEvent(DavUtils.lastSegmentOfUrl(response.href), eTag, scheduleTag, StringReader(iCal))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun postProcess() {
|
||||
@@ -150,28 +139,38 @@ class CalendarSyncManager(
|
||||
|
||||
// helpers
|
||||
|
||||
private fun processVEvent(fileName: String, eTag: String, reader: Reader) {
|
||||
private fun processVEvent(fileName: String, eTag: String, scheduleTag: String?, reader: Reader) {
|
||||
val events: List<Event>
|
||||
try {
|
||||
events = Event.fromReader(reader)
|
||||
events = Event.eventsFromReader(reader)
|
||||
} catch (e: InvalidCalendarException) {
|
||||
Logger.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e)
|
||||
notifyInvalidResource(e, fileName)
|
||||
return
|
||||
}
|
||||
|
||||
if (events.size == 1) {
|
||||
val newData = events.first()
|
||||
val event = events.first()
|
||||
|
||||
// delete local event, if it exists
|
||||
useLocal(localCollection.findByName(fileName)) { local ->
|
||||
// set default reminder for non-full-day events, if requested
|
||||
val defaultAlarmMinBefore = accountSettings.getDefaultAlarm()
|
||||
if (defaultAlarmMinBefore != null && DateUtils.isDateTime(event.dtStart) && event.alarms.isEmpty()) {
|
||||
val alarm = VAlarm(Duration.ofMinutes(-defaultAlarmMinBefore.toLong()))
|
||||
Logger.log.log(Level.FINE, "${event.uid}: Adding default alarm", alarm)
|
||||
event.alarms += alarm
|
||||
}
|
||||
|
||||
// update local event, if it exists
|
||||
localExceptionContext(localCollection.findByName(fileName)) { local ->
|
||||
if (local != null) {
|
||||
Logger.log.info("Updating $fileName in local calendar")
|
||||
Logger.log.log(Level.INFO, "Updating $fileName in local calendar", event)
|
||||
local.eTag = eTag
|
||||
local.update(newData)
|
||||
local.scheduleTag = scheduleTag
|
||||
local.update(event)
|
||||
syncResult.stats.numUpdates++
|
||||
} else {
|
||||
Logger.log.info("Adding $fileName to local calendar")
|
||||
useLocal(LocalEvent(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) {
|
||||
Logger.log.log(Level.INFO, "Adding $fileName to local calendar", event)
|
||||
localExceptionContext(LocalEvent(localCollection, event, fileName, eTag, scheduleTag, LocalResource.FLAG_REMOTELY_PRESENT)) {
|
||||
it.add()
|
||||
}
|
||||
syncResult.stats.numInserts++
|
||||
@@ -181,4 +180,7 @@ class CalendarSyncManager(
|
||||
Logger.log.info("Received VCALENDAR with not exactly one VEVENT with UID and without RECURRENCE-ID; ignoring $fileName")
|
||||
}
|
||||
|
||||
override fun notifyInvalidResourceTitle(): String =
|
||||
context.getString(R.string.sync_invalid_event)
|
||||
|
||||
}
|
||||
|
||||
@@ -8,19 +8,21 @@
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.*
|
||||
import android.database.DatabaseUtils
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.model.Service
|
||||
import at.bitfire.davdroid.resource.LocalCalendar
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import java.util.logging.Level
|
||||
|
||||
class CalendarsSyncAdapterService: SyncAdapterService() {
|
||||
@@ -32,9 +34,9 @@ class CalendarsSyncAdapterService: SyncAdapterService() {
|
||||
context: Context
|
||||
): SyncAdapter(context) {
|
||||
|
||||
override fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
try {
|
||||
val accountSettings = AccountSettings(context, settings, account)
|
||||
val accountSettings = AccountSettings(context, account)
|
||||
|
||||
/* don't run sync if
|
||||
- sync conditions (e.g. "sync only in WiFi") are not met AND
|
||||
@@ -50,9 +52,13 @@ class CalendarsSyncAdapterService: SyncAdapterService() {
|
||||
|
||||
updateLocalCalendars(provider, account, accountSettings)
|
||||
|
||||
for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, "${CalendarContract.Calendars.SYNC_EVENTS}!=0", null)) {
|
||||
val priorityCalendars = priorityCollections(extras)
|
||||
val calendars = AndroidCalendar
|
||||
.find(account, provider, LocalCalendar.Factory, "${CalendarContract.Calendars.SYNC_EVENTS}!=0", null)
|
||||
.sortedByDescending { priorityCalendars.contains(it.id) }
|
||||
for (calendar in calendars) {
|
||||
Logger.log.info("Synchronizing calendar #${calendar.id}, URL: ${calendar.name}")
|
||||
CalendarSyncManager(context, settings, account, accountSettings, extras, authority, syncResult, calendar).use {
|
||||
CalendarSyncManager(context, account, accountSettings, extras, authority, syncResult, calendar).use {
|
||||
it.performSync()
|
||||
}
|
||||
}
|
||||
@@ -63,64 +69,37 @@ class CalendarsSyncAdapterService: SyncAdapterService() {
|
||||
}
|
||||
|
||||
private fun updateLocalCalendars(provider: ContentProviderClient, account: Account, settings: AccountSettings) {
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.readableDatabase
|
||||
val db = AppDatabase.getInstance(context)
|
||||
val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV)
|
||||
|
||||
fun getService() =
|
||||
db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID),
|
||||
"${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
|
||||
arrayOf(account.name, ServiceDB.Services.SERVICE_CALDAV), null, null, null)?.use { c ->
|
||||
if (c.moveToNext())
|
||||
c.getLong(0)
|
||||
else
|
||||
null
|
||||
}
|
||||
|
||||
fun remoteCalendars(service: Long?): MutableMap<HttpUrl, CollectionInfo> {
|
||||
val collections = mutableMapOf<HttpUrl, CollectionInfo>()
|
||||
service?.let {
|
||||
db.query(Collections._TABLE, null,
|
||||
"${Collections.SERVICE_ID}=? AND ${Collections.SUPPORTS_VEVENT}!=0 AND ${Collections.SYNC}",
|
||||
arrayOf(service.toString()), null, null, null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val values = ContentValues(cursor.columnCount)
|
||||
DatabaseUtils.cursorRowToContentValues(cursor, values)
|
||||
val info = CollectionInfo(values)
|
||||
collections[info.url] = info
|
||||
}
|
||||
}
|
||||
}
|
||||
return collections
|
||||
val remoteCalendars = mutableMapOf<HttpUrl, Collection>()
|
||||
if (service != null)
|
||||
for (collection in db.collectionDao().getSyncCalendars(service.id)) {
|
||||
remoteCalendars[collection.url] = collection
|
||||
}
|
||||
|
||||
// enumerate remote and local calendars
|
||||
val service = getService()
|
||||
val remote = remoteCalendars(service)
|
||||
|
||||
// delete/update local calendars
|
||||
val updateColors = settings.getManageCalendarColors()
|
||||
|
||||
for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null))
|
||||
calendar.name?.let {
|
||||
val url = HttpUrl.parse(it)!!
|
||||
val info = remote[url]
|
||||
if (info == null) {
|
||||
Logger.log.log(Level.INFO, "Deleting obsolete local calendar", url)
|
||||
calendar.delete()
|
||||
} else {
|
||||
// remote CollectionInfo found for this local collection, update data
|
||||
Logger.log.log(Level.FINE, "Updating local calendar $url", info)
|
||||
calendar.update(info, updateColors)
|
||||
// we already have a local calendar for this remote collection, don't take into consideration anymore
|
||||
remote -= url
|
||||
}
|
||||
// delete/update local calendars
|
||||
val updateColors = settings.getManageCalendarColors()
|
||||
for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null))
|
||||
calendar.name?.let {
|
||||
val url = it.toHttpUrl()
|
||||
val info = remoteCalendars[url]
|
||||
if (info == null) {
|
||||
Logger.log.log(Level.INFO, "Deleting obsolete local calendar", url)
|
||||
calendar.delete()
|
||||
} else {
|
||||
// remote CollectionInfo found for this local collection, update data
|
||||
Logger.log.log(Level.FINE, "Updating local calendar $url", info)
|
||||
calendar.update(info, updateColors)
|
||||
// we already have a local calendar for this remote collection, don't take into consideration anymore
|
||||
remoteCalendars -= url
|
||||
}
|
||||
|
||||
// create new local calendars
|
||||
for ((_, info) in remote) {
|
||||
Logger.log.log(Level.INFO, "Adding local calendar", info)
|
||||
LocalCalendar.create(account, provider, info)
|
||||
}
|
||||
|
||||
// create new local calendars
|
||||
for ((_, info) in remoteCalendars) {
|
||||
Logger.log.log(Level.INFO, "Adding local calendar", info)
|
||||
LocalCalendar.create(account, provider, info)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,10 +15,9 @@ import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import android.provider.ContactsContract
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import java.util.logging.Level
|
||||
|
||||
class ContactsSyncAdapterService: SyncAdapterService() {
|
||||
@@ -34,10 +33,10 @@ class ContactsSyncAdapterService: SyncAdapterService() {
|
||||
context: Context
|
||||
): SyncAdapter(context) {
|
||||
|
||||
override fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
try {
|
||||
val addressBook = LocalAddressBook(context, account, provider)
|
||||
val accountSettings = AccountSettings(context, settings, addressBook.mainAccount)
|
||||
val accountSettings = AccountSettings(context, addressBook.mainAccount)
|
||||
|
||||
// handle group method change
|
||||
val groupMethod = accountSettings.getGroupMethod().name
|
||||
@@ -55,13 +54,17 @@ class ContactsSyncAdapterService: SyncAdapterService() {
|
||||
}
|
||||
accountSettings.accountManager.setUserData(account, PREVIOUS_GROUP_METHOD, groupMethod)
|
||||
|
||||
/* don't run sync if
|
||||
- sync conditions (e.g. "sync only in WiFi") are not met AND
|
||||
- this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions)
|
||||
*/
|
||||
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings))
|
||||
return
|
||||
|
||||
Logger.log.info("Synchronizing address book: ${addressBook.url}")
|
||||
Logger.log.info("Taking settings from: ${addressBook.mainAccount}")
|
||||
|
||||
ContactsSyncManager(context, settings, account, accountSettings, extras, authority, syncResult, provider, addressBook).use {
|
||||
ContactsSyncManager(context, account, accountSettings, extras, authority, syncResult, provider, addressBook).use {
|
||||
it.performSync()
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
|
||||
@@ -13,29 +13,28 @@ import android.content.*
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.ContactsContract.Groups
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import at.bitfire.dav4android.DavAddressBook
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.DavResponseCallback
|
||||
import at.bitfire.dav4android.Response
|
||||
import at.bitfire.dav4android.exception.DavException
|
||||
import at.bitfire.dav4android.property.*
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.dav4jvm.DavAddressBook
|
||||
import at.bitfire.dav4jvm.DavResponseCallback
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.property.*
|
||||
import at.bitfire.davdroid.DavUtils
|
||||
import at.bitfire.davdroid.HttpClient
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.davdroid.resource.*
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.vcard4android.BatchOperation
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import ezvcard.VCardVersion
|
||||
import ezvcard.io.CannotParseException
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.*
|
||||
import java.util.logging.Level
|
||||
|
||||
@@ -59,10 +58,10 @@ import java.util.logging.Level
|
||||
* to be checked whether its group memberships have changed. In this case, the respective
|
||||
* groups have to be set to dirty. For instance, if contact A is in group G and H, and then
|
||||
* group membership of G is removed, the contact will be set to dirty because of the changed
|
||||
* [android.provider.ContactsContract.CommonDataKinds.GroupMembership]. DAVdroid will
|
||||
* [android.provider.ContactsContract.CommonDataKinds.GroupMembership]. DAVx5 will
|
||||
* then have to check whether the group memberships have actually changed, and if so,
|
||||
* all affected groups have to be set to dirty. To detect changes in group memberships,
|
||||
* DAVdroid always mirrors all [android.provider.ContactsContract.CommonDataKinds.GroupMembership]
|
||||
* DAVx5 always mirrors all [android.provider.ContactsContract.CommonDataKinds.GroupMembership]
|
||||
* data rows in respective [at.bitfire.vcard4android.CachedGroupMembership] rows.
|
||||
* If the cached group memberships are not the same as the current group member ships, the
|
||||
* difference set (in our example G, because its in the cached memberships, but not in the
|
||||
@@ -76,7 +75,6 @@ import java.util.logging.Level
|
||||
*/
|
||||
class ContactsSyncManager(
|
||||
context: Context,
|
||||
settings: ISettings,
|
||||
account: Account,
|
||||
accountSettings: AccountSettings,
|
||||
extras: Bundle,
|
||||
@@ -84,14 +82,13 @@ class ContactsSyncManager(
|
||||
syncResult: SyncResult,
|
||||
val provider: ContentProviderClient,
|
||||
localAddressBook: LocalAddressBook
|
||||
): SyncManager<LocalAddress, LocalAddressBook, DavAddressBook>(context, settings, account, accountSettings, extras, authority, syncResult, localAddressBook) {
|
||||
): SyncManager<LocalAddress, LocalAddressBook, DavAddressBook>(context, account, accountSettings, extras, authority, syncResult, localAddressBook) {
|
||||
|
||||
companion object {
|
||||
infix fun <T> Set<T>.disjunct(other: Set<T>) = (this - other) union (other - this)
|
||||
}
|
||||
|
||||
private val readOnly = localAddressBook.readOnly
|
||||
private var numDiscarded = 0
|
||||
|
||||
private var hasVCard4 = false
|
||||
private val groupMethod = accountSettings.getGroupMethod()
|
||||
@@ -113,7 +110,7 @@ class ContactsSyncManager(
|
||||
}
|
||||
}
|
||||
|
||||
collectionURL = HttpUrl.parse(localCollection.url) ?: return false
|
||||
collectionURL = localCollection.url.toHttpUrlOrNull() ?: return false
|
||||
davCollection = DavAddressBook(httpClient.okHttpClient, collectionURL)
|
||||
|
||||
resourceDownloader = ResourceDownloader(davCollection.location)
|
||||
@@ -126,18 +123,16 @@ class ContactsSyncManager(
|
||||
// in case of GROUP_VCARDs, treat groups as contacts in the local address book
|
||||
localCollection.includeGroups = groupMethod == GroupMethod.GROUP_VCARDS
|
||||
|
||||
return useRemoteCollection {
|
||||
return remoteExceptionContext {
|
||||
var syncState: SyncState? = null
|
||||
it.propfind(0, SupportedAddressData.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF) {
|
||||
response[SupportedAddressData::class.java]?.let {
|
||||
hasVCard4 = it.hasVCard4()
|
||||
response[SupportedAddressData::class.java]?.let { supported ->
|
||||
hasVCard4 = supported.hasVCard4()
|
||||
}
|
||||
|
||||
response[SupportedReportSet::class.java]?.let {
|
||||
hasCollectionSync = it.reports.contains(SupportedReportSet.SYNC_COLLECTION)
|
||||
response[SupportedReportSet::class.java]?.let { supported ->
|
||||
hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION)
|
||||
}
|
||||
|
||||
syncState = syncState(response)
|
||||
}
|
||||
}
|
||||
@@ -154,45 +149,35 @@ class ContactsSyncManager(
|
||||
else
|
||||
SyncAlgorithm.PROPFIND_REPORT
|
||||
|
||||
override fun processLocallyDeleted(): Boolean {
|
||||
if (readOnly) {
|
||||
for (group in localCollection.findDeletedGroups()) {
|
||||
Logger.log.warning("Restoring locally deleted group (read-only address book!)")
|
||||
useLocal(group) { it.resetDeleted() }
|
||||
numDiscarded++
|
||||
}
|
||||
override fun processLocallyDeleted() =
|
||||
if (readOnly) {
|
||||
for (group in localCollection.findDeletedGroups()) {
|
||||
Logger.log.warning("Restoring locally deleted group (read-only address book!)")
|
||||
localExceptionContext(group) { it.resetDeleted() }
|
||||
}
|
||||
|
||||
for (contact in localCollection.findDeletedContacts()) {
|
||||
Logger.log.warning("Restoring locally deleted contact (read-only address book!)")
|
||||
useLocal(contact) { it.resetDeleted() }
|
||||
numDiscarded++
|
||||
}
|
||||
for (contact in localCollection.findDeletedContacts()) {
|
||||
Logger.log.warning("Restoring locally deleted contact (read-only address book!)")
|
||||
localExceptionContext(contact) { it.resetDeleted() }
|
||||
}
|
||||
|
||||
if (numDiscarded > 0)
|
||||
notifyDiscardedChange()
|
||||
return false
|
||||
} else
|
||||
// mirror deletions to remote collection (DELETE)
|
||||
return super.processLocallyDeleted()
|
||||
}
|
||||
false
|
||||
} else
|
||||
// mirror deletions to remote collection (DELETE)
|
||||
super.processLocallyDeleted()
|
||||
|
||||
override fun uploadDirty(): Boolean {
|
||||
if (readOnly) {
|
||||
for (group in localCollection.findDirtyGroups()) {
|
||||
Logger.log.warning("Resetting locally modified group to ETag=null (read-only address book!)")
|
||||
useLocal(group) { it.clearDirty(null) }
|
||||
numDiscarded++
|
||||
localExceptionContext(group) { it.clearDirty(null, null) }
|
||||
}
|
||||
|
||||
for (contact in localCollection.findDirtyContacts()) {
|
||||
Logger.log.warning("Resetting locally modified contact to ETag=null (read-only address book!)")
|
||||
useLocal(contact) { it.clearDirty(null) }
|
||||
numDiscarded++
|
||||
localExceptionContext(contact) { it.clearDirty(null, null) }
|
||||
}
|
||||
|
||||
if (numDiscarded > 0)
|
||||
notifyDiscardedChange()
|
||||
|
||||
} else {
|
||||
if (groupMethod == GroupMethod.CATEGORIES) {
|
||||
/* groups memberships are represented as contact CATEGORIES */
|
||||
@@ -202,15 +187,15 @@ class ContactsSyncManager(
|
||||
Logger.log.fine("Finally removing group $group")
|
||||
// useless because Android deletes group memberships as soon as a group is set to DELETED:
|
||||
// group.markMembersDirty()
|
||||
useLocal(group) { it.delete() }
|
||||
localExceptionContext(group) { it.delete() }
|
||||
}
|
||||
|
||||
// groups with DIRTY=1: mark all memberships as dirty, then clean DIRTY flag of group
|
||||
for (group in localCollection.findDirtyGroups()) {
|
||||
Logger.log.fine("Marking members of modified group $group as dirty")
|
||||
useLocal(group) {
|
||||
localExceptionContext(group) {
|
||||
it.markMembersDirty()
|
||||
it.clearDirty(null)
|
||||
it.clearDirty(null, null)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -241,21 +226,7 @@ class ContactsSyncManager(
|
||||
return super.uploadDirty()
|
||||
}
|
||||
|
||||
private fun notifyDiscardedChange() {
|
||||
val notification = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_STATUS)
|
||||
.setSmallIcon(R.drawable.ic_delete_notification)
|
||||
.setContentTitle(context.getString(R.string.sync_contacts_read_only_address_book))
|
||||
.setContentText(context.resources.getQuantityString(R.plurals.sync_contacts_local_contact_changes_discarded, numDiscarded, numDiscarded))
|
||||
.setNumber(numDiscarded)
|
||||
.setSubText(account.name)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setLocalOnly(true)
|
||||
.build()
|
||||
notificationManager.notify("discarded_${account.name}", 0, notification)
|
||||
}
|
||||
|
||||
override fun prepareUpload(resource: LocalAddress): RequestBody = useLocal(resource) {
|
||||
override fun generateUpload(resource: LocalAddress): RequestBody = localExceptionContext(resource) {
|
||||
val contact: Contact
|
||||
if (resource is LocalContact) {
|
||||
contact = resource.contact!!
|
||||
@@ -285,49 +256,37 @@ class ContactsSyncManager(
|
||||
val os = ByteArrayOutputStream()
|
||||
contact.write(if (hasVCard4) VCardVersion.V4_0 else VCardVersion.V3_0, groupMethod, os)
|
||||
|
||||
RequestBody.create(
|
||||
if (hasVCard4) DavAddressBook.MIME_VCARD4 else DavAddressBook.MIME_VCARD3_UTF8,
|
||||
os.toByteArray()
|
||||
os.toByteArray().toRequestBody(
|
||||
if (hasVCard4) DavAddressBook.MIME_VCARD4 else DavAddressBook.MIME_VCARD3_UTF8
|
||||
)
|
||||
}
|
||||
|
||||
override fun listAllRemote(callback: DavResponseCallback) =
|
||||
useRemoteCollection {
|
||||
remoteExceptionContext {
|
||||
it.propfind(1, ResourceType.NAME, GetETag.NAME, callback = callback)
|
||||
}
|
||||
|
||||
override fun downloadRemote(bunch: List<HttpUrl>) {
|
||||
Logger.log.info("Downloading ${bunch.size} vCards: $bunch")
|
||||
if (bunch.size == 1) {
|
||||
val remote = bunch.first()
|
||||
// only one contact, use GET
|
||||
useRemote(DavResource(httpClient.okHttpClient, remote)) { resource ->
|
||||
resource.get("text/vcard;version=4.0, text/vcard;charset=utf-8;q=0.8, text/vcard;q=0.5") { response ->
|
||||
// CardDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc6352#section-6.3.2.3]
|
||||
val eTag = response.header("ETag")?.let { GetETag(it).eTag }
|
||||
?: throw DavException("Received CardDAV GET response without ETag")
|
||||
|
||||
response.body()!!.use {
|
||||
processVCard(resource.fileName(), eTag, it.charStream(), resourceDownloader)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else
|
||||
// multiple vCards, use addressbook-multi-get
|
||||
useRemoteCollection {
|
||||
it.multiget(bunch, hasVCard4) { response, _ ->
|
||||
useRemote(response) {
|
||||
val eTag = response[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
|
||||
val addressData = response[AddressData::class.java]
|
||||
val vCard = addressData?.vCard
|
||||
?: throw DavException("Received multi-get response without address data")
|
||||
|
||||
processVCard(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(vCard), resourceDownloader)
|
||||
Logger.log.info("Downloading ${bunch.size} vCard(s): $bunch")
|
||||
remoteExceptionContext {
|
||||
it.multiget(bunch, hasVCard4) { response, _ ->
|
||||
responseExceptionContext(response) {
|
||||
if (!response.isSuccess()) {
|
||||
Logger.log.warning("Received non-successful multiget response for ${response.href}")
|
||||
return@responseExceptionContext
|
||||
}
|
||||
|
||||
val eTag = response[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
|
||||
val addressData = response[AddressData::class.java]
|
||||
val vCard = addressData?.vCard
|
||||
?: throw DavException("Received multi-get response without address data")
|
||||
|
||||
processVCard(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(vCard), resourceDownloader)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun postProcess() {
|
||||
@@ -350,22 +309,30 @@ class ContactsSyncManager(
|
||||
|
||||
private fun processVCard(fileName: String, eTag: String, reader: Reader, downloader: Contact.Downloader) {
|
||||
Logger.log.info("Processing CardDAV resource $fileName")
|
||||
val contacts = Contact.fromReader(reader, downloader)
|
||||
|
||||
val contacts = try {
|
||||
Contact.fromReader(reader, downloader)
|
||||
} catch (e: CannotParseException) {
|
||||
Logger.log.log(Level.SEVERE, "Received invalid vCard, ignoring", e)
|
||||
notifyInvalidResource(e, fileName)
|
||||
return
|
||||
}
|
||||
|
||||
if (contacts.isEmpty()) {
|
||||
Logger.log.warning("Received VCard without data, ignoring")
|
||||
Logger.log.warning("Received vCard without data, ignoring")
|
||||
return
|
||||
} else if (contacts.size > 1)
|
||||
Logger.log.warning("Received multiple VCards, using first one")
|
||||
Logger.log.warning("Received multiple vCards, using first one")
|
||||
|
||||
val newData = contacts.first()
|
||||
|
||||
if (groupMethod == GroupMethod.CATEGORIES && newData.group) {
|
||||
Logger.log.warning("Received group VCard although group method is CATEGORIES. Saving as regular contact")
|
||||
Logger.log.warning("Received group vCard although group method is CATEGORIES. Saving as regular contact")
|
||||
newData.group = false
|
||||
}
|
||||
|
||||
// update local contact, if it exists
|
||||
useLocal(localCollection.findByName(fileName)) {
|
||||
localExceptionContext(localCollection.findByName(fileName)) {
|
||||
var local = it
|
||||
if (local != null) {
|
||||
Logger.log.log(Level.INFO, "Updating $fileName in local address book", newData)
|
||||
@@ -394,15 +361,15 @@ class ContactsSyncManager(
|
||||
if (local == null) {
|
||||
if (newData.group) {
|
||||
Logger.log.log(Level.INFO, "Creating local group", newData)
|
||||
useLocal(LocalGroup(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) {
|
||||
it.add()
|
||||
local = it
|
||||
localExceptionContext(LocalGroup(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { group ->
|
||||
group.add()
|
||||
local = group
|
||||
}
|
||||
} else {
|
||||
Logger.log.log(Level.INFO, "Creating local contact", newData)
|
||||
useLocal(LocalContact(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) {
|
||||
it.add()
|
||||
local = it
|
||||
localExceptionContext(LocalContact(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { contact ->
|
||||
contact.add()
|
||||
local = contact
|
||||
}
|
||||
}
|
||||
syncResult.stats.numInserts++
|
||||
@@ -438,20 +405,14 @@ class ContactsSyncManager(
|
||||
): Contact.Downloader {
|
||||
|
||||
override fun download(url: String, accepts: String): ByteArray? {
|
||||
val httpUrl = HttpUrl.parse(url)
|
||||
val httpUrl = url.toHttpUrlOrNull()
|
||||
if (httpUrl == null) {
|
||||
Logger.log.log(Level.SEVERE, "Invalid external resource URL", url)
|
||||
return null
|
||||
}
|
||||
|
||||
val host = httpUrl.host()
|
||||
if (host == null) {
|
||||
Logger.log.log(Level.SEVERE, "External resource URL doesn't specify a host name", url)
|
||||
return null
|
||||
}
|
||||
|
||||
// authenticate only against a certain host, and only upon request
|
||||
val builder = HttpClient.Builder(context, baseUrl.host(), accountSettings.credentials())
|
||||
val builder = HttpClient.Builder(context, baseUrl.host, accountSettings.credentials())
|
||||
|
||||
// allow redirects
|
||||
builder.followRedirects(true)
|
||||
@@ -464,7 +425,7 @@ class ContactsSyncManager(
|
||||
.build()).execute()
|
||||
|
||||
if (response.isSuccessful)
|
||||
return response.body()?.bytes()
|
||||
return response.body?.bytes()
|
||||
else
|
||||
Logger.log.warning("Couldn't download external resource")
|
||||
} catch(e: IOException) {
|
||||
@@ -476,4 +437,7 @@ class ContactsSyncManager(
|
||||
}
|
||||
}
|
||||
|
||||
override fun notifyInvalidResourceTitle(): String =
|
||||
context.getString(R.string.sync_invalid_contact)
|
||||
|
||||
}
|
||||
|
||||
@@ -1,30 +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.syncadapter
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
|
||||
interface ISyncPlugin {
|
||||
|
||||
/**
|
||||
* Called before synchronization within a sync adapter is started. Can be used for
|
||||
* license checks etc. Must be thread-safe.
|
||||
* @return whether synchronization shall take place (false to abort)
|
||||
*/
|
||||
fun beforeSync(context: Context, settings: ISettings, syncResult: SyncResult): Boolean
|
||||
|
||||
/**
|
||||
* Called at the end of a synchronization adapter call, regardless of whether the synchronization
|
||||
* was actually run (i.e. what [beforeSync] had returned). Must be thread-safe.
|
||||
*/
|
||||
fun afterSync(context: Context, settings: ISettings, syncResult: SyncResult)
|
||||
|
||||
}
|
||||
@@ -26,7 +26,7 @@ class NullAuthenticatorService: Service() {
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?) =
|
||||
accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT }
|
||||
accountAuthenticator.iBinder.takeIf { intent?.action == AccountManager.ACTION_AUTHENTICATOR_INTENT }
|
||||
|
||||
|
||||
private class AccountAuthenticator(
|
||||
|
||||
@@ -10,25 +10,20 @@ package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import android.support.v4.app.NotificationManagerCompat
|
||||
import android.support.v4.content.ContextCompat
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.R
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import at.bitfire.davdroid.PermissionUtils
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.ui.AccountActivity
|
||||
import at.bitfire.davdroid.ui.AccountSettingsActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.ui.account.SettingsActivity
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
@@ -41,6 +36,38 @@ abstract class SyncAdapterService: Service() {
|
||||
* is terminated and the `finally` block which cleans up [runningSyncs] is not
|
||||
* executed. */
|
||||
private val runningSyncs = mutableListOf<WeakReference<Pair<String, Account>>>()
|
||||
|
||||
/**
|
||||
* Specifies an list of IDs which are requested to be synchronized before
|
||||
* the other collections. For instance, if some calendars of a CalDAV
|
||||
* account are visible in the calendar app and others are hidden, the visible calendars can
|
||||
* be synchronized first, so that the "Refresh" action in the calendar app is more responsive.
|
||||
*
|
||||
* Extra type: String (comma-separated list of IDs)
|
||||
*
|
||||
* In case of calendar sync, the extra value is a list of Android calendar IDs.
|
||||
* In case of task sync, the extra value is an a list of OpenTask task list IDs.
|
||||
*/
|
||||
const val SYNC_EXTRAS_PRIORITY_COLLECTIONS = "priority_collections"
|
||||
|
||||
/**
|
||||
* Requests a re-synchronization of all entries. For instance, if this extra is
|
||||
* set for a calendar sync, all remote events will be listed and checked for remote
|
||||
* changes again.
|
||||
*
|
||||
* Useful if settings which modify the remote resource list (like the CalDAV setting
|
||||
* "sync events n days in the past") have been changed.
|
||||
*/
|
||||
const val SYNC_EXTRAS_RESYNC = "resync"
|
||||
|
||||
/**
|
||||
* Requests a full re-synchronization of all entries. For instance, if this extra is
|
||||
* set for an address book sync, all contacts will be downloaded again and updated in the
|
||||
* local storage.
|
||||
*
|
||||
* Useful if settings which modify parsing/local behavior have been changed.
|
||||
*/
|
||||
const val SYNC_EXTRAS_FULL_RESYNC = "full_resync"
|
||||
}
|
||||
|
||||
protected abstract fun syncAdapter(): AbstractThreadedSyncAdapter
|
||||
@@ -53,17 +80,21 @@ abstract class SyncAdapterService: Service() {
|
||||
): AbstractThreadedSyncAdapter(context, false) {
|
||||
|
||||
companion object {
|
||||
private val syncPluginLoader = ServiceLoader.load(ISyncPlugin::class.java)
|
||||
fun priorityCollections(extras: Bundle): Set<Long> {
|
||||
val ids = mutableSetOf<Long>()
|
||||
extras.getString(SYNC_EXTRAS_PRIORITY_COLLECTIONS)?.let { rawIds ->
|
||||
for (rawId in rawIds.split(','))
|
||||
try {
|
||||
ids += rawId.toLong()
|
||||
} catch (e: NumberFormatException) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't parse SYNC_EXTRAS_PRIORITY_COLLECTIONS", e)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
}
|
||||
|
||||
private val syncPlugins = syncPluginLoader.iterator().asSequence().toList()
|
||||
|
||||
init {
|
||||
syncPlugins.forEach { Logger.log.info("Registered sync plugin: ${it::class.java.name}") }
|
||||
}
|
||||
|
||||
|
||||
abstract fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult)
|
||||
abstract fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult)
|
||||
|
||||
override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
Logger.log.log(Level.INFO, "$authority sync of $account has been initiated", extras.keySet().joinToString(", "))
|
||||
@@ -78,27 +109,12 @@ abstract class SyncAdapterService: Service() {
|
||||
runningSyncs += WeakReference(currentSync)
|
||||
}
|
||||
|
||||
// required for ServiceLoader -> ical4j -> ical4android
|
||||
Thread.currentThread().contextClassLoader = context.classLoader
|
||||
|
||||
try {
|
||||
// required for dav4android (ServiceLoader)
|
||||
Thread.currentThread().contextClassLoader = context.classLoader
|
||||
|
||||
// load app settings
|
||||
Settings.getInstance(context).use { settings ->
|
||||
if (settings == null) {
|
||||
syncResult.databaseError = true
|
||||
Logger.log.severe("Couldn't connect to Settings service, aborting sync")
|
||||
return
|
||||
}
|
||||
|
||||
val runSync = syncPlugins.all { it.beforeSync(context, settings, syncResult) }
|
||||
|
||||
if (runSync) {
|
||||
SyncManager.cancelNotifications(NotificationManagerCompat.from(context), authority, account)
|
||||
sync(settings, account, extras, authority, provider, syncResult)
|
||||
}
|
||||
|
||||
syncPlugins.forEach { it.afterSync(context, settings, syncResult) }
|
||||
}
|
||||
if (true)
|
||||
sync(account, extras, authority, provider, syncResult)
|
||||
} finally {
|
||||
synchronized(runningSyncs) {
|
||||
runningSyncs.removeAll { it.get() == null || it.get() == currentSync }
|
||||
@@ -110,37 +126,51 @@ abstract class SyncAdapterService: Service() {
|
||||
|
||||
override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
|
||||
Logger.log.log(Level.WARNING, "Security exception when opening content provider for $authority")
|
||||
syncResult.databaseError = true
|
||||
|
||||
val intent = Intent(context, AccountActivity::class.java)
|
||||
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
notifyPermissions(intent)
|
||||
}
|
||||
|
||||
override fun onSyncCanceled() {
|
||||
Logger.log.info("Sync thread cancelled! Interrupting sync")
|
||||
super.onSyncCanceled()
|
||||
}
|
||||
|
||||
override fun onSyncCanceled(thread: Thread) {
|
||||
Logger.log.info("Sync thread ${thread.id} cancelled! Interrupting sync")
|
||||
super.onSyncCanceled(thread)
|
||||
}
|
||||
|
||||
|
||||
protected fun checkSyncConditions(settings: AccountSettings): Boolean {
|
||||
if (settings.getSyncWifiOnly()) {
|
||||
val connectivityManager = context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val network = connectivityManager.activeNetworkInfo
|
||||
if (network == null || network.type != ConnectivityManager.TYPE_WIFI || !network.isConnected) {
|
||||
// WiFi required
|
||||
val connectivityManager = context.getSystemService<ConnectivityManager>()!!
|
||||
|
||||
// check for connected WiFi network
|
||||
var wifiAvailable = false
|
||||
connectivityManager.allNetworks.forEach { network ->
|
||||
connectivityManager.getNetworkCapabilities(network)?.let { capabilities ->
|
||||
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
|
||||
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))
|
||||
wifiAvailable = true
|
||||
}
|
||||
}
|
||||
if (!wifiAvailable) {
|
||||
Logger.log.info("Not on connected WiFi, stopping")
|
||||
return false
|
||||
}
|
||||
// if execution reaches this point, we're on a connected WiFi
|
||||
|
||||
settings.getSyncWifiOnlySSIDs()?.let { onlySSIDs ->
|
||||
// getting the WiFi name requires location permission (and active location services) since Android 8.1
|
||||
// see https://issuetracker.google.com/issues/70633700
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
|
||||
val intent = Intent(context, AccountSettingsActivity::class.java)
|
||||
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, settings.account)
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
|
||||
val intent = Intent(context, SettingsActivity::class.java)
|
||||
intent.putExtra(SettingsActivity.EXTRA_ACCOUNT, settings.account)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
notifyPermissions(intent)
|
||||
PermissionUtils.notifyPermissions(context, intent)
|
||||
}
|
||||
|
||||
val wifi = context.applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
|
||||
val wifi = context.getSystemService<WifiManager>()!!
|
||||
val info = wifi.connectionInfo
|
||||
if (info == null || !onlySSIDs.contains(info.ssid.trim('"'))) {
|
||||
Logger.log.info("Connected to wrong WiFi network (${info.ssid}), ignoring")
|
||||
@@ -151,18 +181,6 @@ abstract class SyncAdapterService: Service() {
|
||||
return true
|
||||
}
|
||||
|
||||
protected fun notifyPermissions(intent: Intent) {
|
||||
val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_ERRORS)
|
||||
.setSmallIcon(R.drawable.ic_sync_error_notification)
|
||||
.setContentTitle(context.getString(R.string.sync_error_permissions))
|
||||
.setContentText(context.getString(R.string.sync_error_permissions_text))
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
NotificationManagerCompat.from(context).notify(NotificationUtils.NOTIFY_PERMISSIONS, notify)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -10,33 +10,43 @@ package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.PendingIntent
|
||||
import android.content.*
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SyncResult
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.RemoteException
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import android.support.v4.app.NotificationManagerCompat
|
||||
import at.bitfire.dav4android.*
|
||||
import at.bitfire.dav4android.exception.*
|
||||
import at.bitfire.dav4android.property.GetCTag
|
||||
import at.bitfire.dav4android.property.GetETag
|
||||
import at.bitfire.dav4android.property.SyncToken
|
||||
import at.bitfire.davdroid.*
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import at.bitfire.dav4jvm.*
|
||||
import at.bitfire.dav4jvm.exception.*
|
||||
import at.bitfire.dav4jvm.property.GetCTag
|
||||
import at.bitfire.dav4jvm.property.GetETag
|
||||
import at.bitfire.dav4jvm.property.ScheduleTag
|
||||
import at.bitfire.dav4jvm.property.SyncToken
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.DavService
|
||||
import at.bitfire.davdroid.HttpClient
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.davdroid.resource.*
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import at.bitfire.davdroid.ui.AccountSettingsActivity
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.davdroid.ui.account.SettingsActivity
|
||||
import at.bitfire.ical4android.CalendarStorageException
|
||||
import at.bitfire.ical4android.Ical4Android
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.ical4android.UsesThreadContextClassLoader
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.RequestBody
|
||||
import org.apache.commons.lang3.exception.ContextedException
|
||||
@@ -46,15 +56,19 @@ import java.io.InterruptedIOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.security.cert.CertificateException
|
||||
import java.util.*
|
||||
import java.util.concurrent.*
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.Future
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.logging.Level
|
||||
import javax.net.ssl.SSLHandshakeException
|
||||
import kotlin.math.min
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
@UsesThreadContextClassLoader
|
||||
abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: LocalCollection<ResourceType>, RemoteType: DavCollection>(
|
||||
val context: Context,
|
||||
val settings: ISettings,
|
||||
val account: Account,
|
||||
val accountSettings: AccountSettings,
|
||||
val extras: Bundle,
|
||||
@@ -69,28 +83,31 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
val MAX_PROCESSING_THREADS = Math.max(Runtime.getRuntime().availableProcessors(), 4)
|
||||
val MAX_DOWNLOAD_THREADS = Math.max(Runtime.getRuntime().availableProcessors(), 4)
|
||||
const val MAX_MULTIGET_RESOURCES = 10
|
||||
|
||||
fun cancelNotifications(manager: NotificationManagerCompat, authority: String, account: Account) =
|
||||
manager.cancel(notificationTag(authority, account), NotificationUtils.NOTIFY_SYNC_ERROR)
|
||||
|
||||
private fun notificationTag(authority: String, account: Account) =
|
||||
"$authority-${account.name}".hashCode().toString()
|
||||
|
||||
}
|
||||
|
||||
init {
|
||||
// required for ServiceLoader -> ical4j -> ical4android
|
||||
Ical4Android.checkThreadContextClassLoader()
|
||||
}
|
||||
/**
|
||||
* We use our own dispatcher to make sure that all threads have [Thread.getContextClassLoader] set,
|
||||
* which is required for dav4jvm and ical4j (because they rely on [ServiceLoader]).
|
||||
*/
|
||||
private val workDispatcher = Executors.newFixedThreadPool(
|
||||
// number of threads = number of CPUs, but max. 4
|
||||
min(Runtime.getRuntime().availableProcessors(), 4)
|
||||
).asCoroutineDispatcher()
|
||||
|
||||
private val mainAccount = if (localCollection is LocalAddressBook)
|
||||
localCollection.mainAccount
|
||||
else
|
||||
account
|
||||
|
||||
protected val notificationManager = NotificationManagerCompat.from(context)
|
||||
protected val notificationTag = notificationTag(authority, mainAccount)
|
||||
protected val notificationTag = localCollection.tag
|
||||
|
||||
protected val httpClient = HttpClient.Builder(context, settings, accountSettings).build()
|
||||
protected val httpClient = HttpClient.Builder(context, accountSettings).build()
|
||||
|
||||
protected lateinit var collectionURL: HttpUrl
|
||||
protected lateinit var davCollection: RemoteType
|
||||
@@ -112,16 +129,24 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
Logger.log.info("No reason to synchronize, aborting")
|
||||
return@unwrapExceptions
|
||||
}
|
||||
abortIfCancelled()
|
||||
|
||||
Logger.log.info("Querying server capabilities")
|
||||
var remoteSyncState = queryCapabilities()
|
||||
abortIfCancelled()
|
||||
|
||||
Logger.log.info("Sending local deletes/updates to server")
|
||||
val modificationsSent = processLocallyDeleted() ||
|
||||
uploadDirty()
|
||||
abortIfCancelled()
|
||||
|
||||
if (extras.containsKey(SyncAdapterService.SYNC_EXTRAS_FULL_RESYNC)) {
|
||||
Logger.log.info("Forcing re-synchronization of all entries")
|
||||
|
||||
// forget sync state of collection (→ initial sync in case of SyncAlgorithm.COLLECTION_SYNC)
|
||||
localCollection.lastSyncState = null
|
||||
remoteSyncState = null
|
||||
|
||||
// forget sync state of members (→ download all members again and update them locally)
|
||||
localCollection.forgetETags()
|
||||
}
|
||||
|
||||
if (modificationsSent || syncRequired(remoteSyncState))
|
||||
when (syncAlgorithm()) {
|
||||
@@ -140,7 +165,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
}
|
||||
|
||||
Logger.log.info("Deleting entries which are not present remotely anymore")
|
||||
syncResult.stats.numDeletes += deleteNotPresentRemotely()
|
||||
deleteNotPresentRemotely()
|
||||
|
||||
Logger.log.info("Post-processing")
|
||||
postProcess()
|
||||
@@ -149,9 +174,9 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
localCollection.lastSyncState = remoteSyncState
|
||||
}
|
||||
SyncAlgorithm.COLLECTION_SYNC -> {
|
||||
var initialSync = false
|
||||
|
||||
var syncState = localCollection.lastSyncState?.takeIf { it.type == SyncState.Type.SYNC_TOKEN }
|
||||
|
||||
var initialSync = false
|
||||
if (syncState == null) {
|
||||
Logger.log.info("Starting initial sync")
|
||||
initialSync = true
|
||||
@@ -170,7 +195,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
syncState = SyncState.fromSyncToken(result.first, initialSync)
|
||||
furtherChanges = result.second
|
||||
} catch(e: HttpException) {
|
||||
if (e.errors.any { it.name == Property.Name(XmlUtils.NS_WEBDAV, "valid-sync-token") }) {
|
||||
if (e.errors.contains(Error.VALID_SYNC_TOKEN)) {
|
||||
Logger.log.info("Sync token invalid, performing initial sync")
|
||||
initialSync = true
|
||||
resetPresentRemotely()
|
||||
@@ -181,11 +206,11 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
} else
|
||||
throw e
|
||||
}
|
||||
|
||||
Logger.log.log(Level.INFO, "Saving sync state", syncState)
|
||||
localCollection.lastSyncState = syncState
|
||||
}
|
||||
|
||||
Logger.log.log(Level.INFO, "Saving sync state", syncState)
|
||||
localCollection.lastSyncState = syncState
|
||||
|
||||
Logger.log.info("Server has further changes: $furtherChanges")
|
||||
} while(furtherChanges)
|
||||
|
||||
@@ -219,7 +244,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
Logger.log.log(Level.WARNING, "SSL handshake failed", e)
|
||||
|
||||
// when a certificate is rejected by cert4android, the cause will be a CertificateException
|
||||
if (!BuildConfig.customCerts || e.cause !is CertificateException)
|
||||
if (e.cause !is CertificateException)
|
||||
notifyException(e, local, remote)
|
||||
}
|
||||
|
||||
@@ -264,15 +289,16 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
// but only if they don't have changed on the server. Then finally remove them from the local address book.
|
||||
val localList = localCollection.findDeleted()
|
||||
for (local in localList) {
|
||||
abortIfCancelled()
|
||||
useLocal(local) {
|
||||
localExceptionContext(local) {
|
||||
val fileName = local.fileName
|
||||
if (fileName != null) {
|
||||
Logger.log.info("$fileName has been deleted locally -> deleting from server (ETag ${local.eTag})")
|
||||
val lastScheduleTag = local.scheduleTag
|
||||
val lastETag = if (lastScheduleTag == null) local.eTag else null
|
||||
Logger.log.info("$fileName has been deleted locally -> deleting from server (ETag $lastETag / schedule-tag $lastScheduleTag)")
|
||||
|
||||
useRemote(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build())) { remote ->
|
||||
remoteExceptionContext(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build())) { remote ->
|
||||
try {
|
||||
remote.delete(local.eTag) {}
|
||||
remote.delete(ifETag = lastETag, ifScheduleTag = lastScheduleTag) {}
|
||||
numDeleted++
|
||||
} catch (e: HttpException) {
|
||||
Logger.log.warning("Couldn't delete $fileName from server; ignoring (may be downloaded again)")
|
||||
@@ -289,63 +315,89 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads locally modified resources to the server (HTTP `PUT`).
|
||||
* Uploads locally modified resources to the server.
|
||||
*
|
||||
* @return whether resources have been uploaded
|
||||
*/
|
||||
protected open fun uploadDirty(): Boolean {
|
||||
var numUploaded = 0
|
||||
|
||||
// upload dirty contacts
|
||||
for (local in localCollection.findDirty())
|
||||
useLocal(local) {
|
||||
abortIfCancelled()
|
||||
|
||||
if (local.fileName == null) {
|
||||
Logger.log.fine("Generating file name/UID for local record #${local.id}")
|
||||
local.assignNameAndUID()
|
||||
// upload dirty resources (parallelized)
|
||||
runBlocking(workDispatcher) {
|
||||
for (local in localCollection.findDirty())
|
||||
launch {
|
||||
uploadDirty(local)
|
||||
numUploaded++
|
||||
}
|
||||
|
||||
val fileName = local.fileName!!
|
||||
useRemote(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build())) { remote ->
|
||||
// generate entity to upload (VCard, iCal, whatever)
|
||||
val body = prepareUpload(local)
|
||||
|
||||
var eTag: String? = null
|
||||
val processETag: (response: okhttp3.Response) -> Unit = {
|
||||
it.header("ETag")?.let {
|
||||
eTag = GetETag(it).eTag
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (local.eTag == null) {
|
||||
Logger.log.info("Uploading new record $fileName")
|
||||
remote.put(body, null, true, processETag)
|
||||
} else {
|
||||
Logger.log.info("Uploading locally modified record $fileName")
|
||||
remote.put(body, local.eTag, false, processETag)
|
||||
}
|
||||
numUploaded++
|
||||
} catch(e: ConflictException) {
|
||||
// we can't interact with the user to resolve the conflict, so we treat 409 like 412
|
||||
Logger.log.log(Level.INFO, "Edit conflict, ignoring", e)
|
||||
} catch(e: PreconditionFailedException) {
|
||||
Logger.log.log(Level.INFO, "Resource has been modified on the server before upload, ignoring", e)
|
||||
}
|
||||
|
||||
if (eTag != null)
|
||||
Logger.log.fine("Received new ETag=$eTag after uploading")
|
||||
else
|
||||
Logger.log.fine("Didn't receive new ETag after uploading, setting to null")
|
||||
|
||||
local.clearDirty(eTag)
|
||||
}
|
||||
}
|
||||
}
|
||||
syncResult.stats.numEntries += numUploaded
|
||||
Logger.log.info("Sent $numUploaded record(s) to server")
|
||||
return numUploaded > 0
|
||||
}
|
||||
|
||||
protected abstract fun prepareUpload(resource: ResourceType): RequestBody
|
||||
protected fun uploadDirty(local: ResourceType) {
|
||||
val existingFileName = local.fileName
|
||||
|
||||
var newFileName: String? = null
|
||||
var eTag: String? = null
|
||||
var scheduleTag: String? = null
|
||||
val readTagsFromResponse: (okhttp3.Response) -> Unit = { response ->
|
||||
eTag = GetETag.fromResponse(response)?.eTag
|
||||
scheduleTag = ScheduleTag.fromResponse(response)?.scheduleTag
|
||||
}
|
||||
|
||||
try {
|
||||
if (existingFileName == null) { // new resource
|
||||
newFileName = local.prepareForFirstUpload()
|
||||
val uploadUrl = collectionURL.newBuilder().addPathSegment(newFileName).build()
|
||||
remoteExceptionContext(DavResource(httpClient.okHttpClient, uploadUrl)) { remote ->
|
||||
Logger.log.info("Uploading new record ${local.id} -> $newFileName")
|
||||
remote.put(generateUpload(local), ifNoneMatch = true, callback = readTagsFromResponse)
|
||||
}
|
||||
|
||||
} else /* existingFileName != null */ { // updated resource
|
||||
val uploadUrl = collectionURL.newBuilder().addPathSegment(existingFileName).build()
|
||||
remoteExceptionContext(DavResource(httpClient.okHttpClient, uploadUrl)) { remote ->
|
||||
val lastScheduleTag = local.scheduleTag
|
||||
val lastETag = if (lastScheduleTag == null) local.eTag else null
|
||||
Logger.log.info("Uploading modified record ${local.id} -> $newFileName (ETag=$lastETag, Schedule-Tag=$lastScheduleTag)")
|
||||
remote.put(generateUpload(local), ifETag = lastETag, ifScheduleTag = lastScheduleTag, callback = readTagsFromResponse)
|
||||
}
|
||||
}
|
||||
} catch(e: ForbiddenException) {
|
||||
// HTTP 403 Forbidden
|
||||
// If and only if the upload failed because of missing permissions, treat it like 412.
|
||||
if (e.errors.contains(Error.NEED_PRIVILEGES))
|
||||
Logger.log.log(Level.INFO, "Couldn't upload because of missing permissions, ignoring", e)
|
||||
else
|
||||
throw e
|
||||
} catch(e: ConflictException) {
|
||||
// HTTP 409 Conflict
|
||||
// We can't interact with the user to resolve the conflict, so we treat 409 like 412.
|
||||
Logger.log.log(Level.INFO, "Edit conflict, ignoring", e)
|
||||
} catch(e: PreconditionFailedException) {
|
||||
// HTTP 412 Precondition failed: Resource has been modified on the server in the meanwhile.
|
||||
// Ignore this condition so that the resource can be downloaded and reset again.
|
||||
Logger.log.log(Level.INFO, "Resource has been modified on the server before upload, ignoring", e)
|
||||
}
|
||||
|
||||
if (eTag != null)
|
||||
Logger.log.fine("Received new ETag=$eTag after uploading")
|
||||
else
|
||||
Logger.log.fine("Didn't receive new ETag after uploading, setting to null")
|
||||
|
||||
local.clearDirty(newFileName, eTag, scheduleTag)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the request body (iCalendar or vCard) from a local resource.
|
||||
*
|
||||
* @param resource local resource to generate the body from
|
||||
*
|
||||
* @return iCalendar or vCard (content + Content-Type) that can be uploaded to the server
|
||||
*/
|
||||
protected abstract fun generateUpload(resource: ResourceType): RequestBody
|
||||
|
||||
|
||||
/**
|
||||
* Determines whether a sync is required because there were changes on the server.
|
||||
@@ -356,16 +408,18 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
* [uploadDirty] were true), a sync is always required and this method
|
||||
* should *not* be evaluated.
|
||||
*
|
||||
* Will return _true_ if [SyncAdapterService.SYNC_EXTRAS_RESYNC] and/or
|
||||
* [SyncAdapterService.SYNC_EXTRAS_FULL_RESYNC] is set in [extras].
|
||||
*
|
||||
* @param state remote sync state to compare local sync state with
|
||||
*
|
||||
* @return whether data has been changed on the server, i.e. whether running the
|
||||
* sync algorithm is required
|
||||
*/
|
||||
protected open fun syncRequired(state: SyncState?): Boolean {
|
||||
if (syncAlgorithm() == SyncAlgorithm.PROPFIND_REPORT && extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL)) {
|
||||
Logger.log.info("Manual sync in PROPFIND/REPORT mode, forcing sync")
|
||||
if (extras.containsKey(SyncAdapterService.SYNC_EXTRAS_RESYNC) ||
|
||||
extras.containsKey(SyncAdapterService.SYNC_EXTRAS_FULL_RESYNC))
|
||||
return true
|
||||
}
|
||||
|
||||
val localState = localCollection.lastSyncState
|
||||
Logger.log.info("Local sync state = $localState, remote sync state = $state")
|
||||
@@ -404,115 +458,94 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
Logger.log.info("Number of local non-dirty entries: $number")
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a callback to list remote resources. All resources from the returned
|
||||
* list are downloaded and processed.
|
||||
*
|
||||
* @param listRemote function to list remote resources (for instance, all since a certain sync-token)
|
||||
*/
|
||||
protected open fun syncRemote(listRemote: (DavResponseCallback) -> Unit) {
|
||||
// results must be processed in main thread because exceptions must be thrown in main
|
||||
// thread, so that they can be catched by SyncManager
|
||||
val results = ConcurrentLinkedQueue<Future<*>>()
|
||||
|
||||
// thread-safe sync stats
|
||||
val nInserted = AtomicInteger()
|
||||
val nUpdated = AtomicInteger()
|
||||
val nDeleted = AtomicInteger()
|
||||
val nSkipped = AtomicInteger()
|
||||
|
||||
// download queue
|
||||
val toDownload = LinkedBlockingQueue<HttpUrl>()
|
||||
runBlocking(workDispatcher) {
|
||||
// download queue
|
||||
val toDownload = LinkedBlockingQueue<HttpUrl>()
|
||||
fun download(url: HttpUrl?) {
|
||||
if (url != null)
|
||||
toDownload += url
|
||||
|
||||
// tasks from this executor create the download tasks (if necessary)
|
||||
val processor = ThreadPoolExecutor(1, MAX_PROCESSING_THREADS,
|
||||
10, TimeUnit.SECONDS,
|
||||
LinkedBlockingQueue(MAX_PROCESSING_THREADS), // accept up to MAX_PROCESSING_THREADS processing tasks
|
||||
ThreadPoolExecutor.CallerRunsPolicy() // if the queue is full, run task in submitting thread
|
||||
)
|
||||
|
||||
// this executor runs the actual download tasks
|
||||
val downloader = ThreadPoolExecutor(0, MAX_DOWNLOAD_THREADS,
|
||||
10, TimeUnit.SECONDS,
|
||||
LinkedBlockingQueue(MAX_DOWNLOAD_THREADS), // accept up to MAX_DOWNLOAD_THREADS download tasks
|
||||
ThreadPoolExecutor.CallerRunsPolicy() // if the queue is full, run task in submitting thread
|
||||
)
|
||||
fun downloadBunch() {
|
||||
val bunch = LinkedList<HttpUrl>()
|
||||
toDownload.drainTo(bunch, MAX_MULTIGET_RESOURCES)
|
||||
results += downloader.submit {
|
||||
downloadRemote(bunch)
|
||||
}
|
||||
}
|
||||
|
||||
listRemote { response, relation ->
|
||||
// ignore non-members
|
||||
if (relation != Response.HrefRelation.MEMBER)
|
||||
return@listRemote
|
||||
|
||||
// ignore collections
|
||||
if (response[at.bitfire.dav4android.property.ResourceType::class.java]?.types?.contains(at.bitfire.dav4android.property.ResourceType.COLLECTION) == true)
|
||||
return@listRemote
|
||||
|
||||
val name = response.hrefName()
|
||||
|
||||
if (response.isSuccess()) {
|
||||
Logger.log.fine("Found remote resource: $name")
|
||||
|
||||
results += processor.submit {
|
||||
useLocal(localCollection.findByName(name)) { local ->
|
||||
if (local == null) {
|
||||
Logger.log.info("$name has been added remotely")
|
||||
toDownload += response.href
|
||||
nInserted.incrementAndGet()
|
||||
} else {
|
||||
val localETag = local.eTag
|
||||
val remoteETag = response[GetETag::class.java]?.eTag ?: throw DavException("Server didn't provide ETag")
|
||||
if (localETag == remoteETag) {
|
||||
Logger.log.info("$name has not been changed on server (ETag still $remoteETag)")
|
||||
nSkipped.incrementAndGet()
|
||||
} else {
|
||||
Logger.log.info("$name has been changed on server (current ETag=$remoteETag, last known ETag=$localETag)")
|
||||
toDownload += response.href
|
||||
nUpdated.incrementAndGet()
|
||||
}
|
||||
|
||||
// mark as remotely present, so that this resource won't be deleted at the end
|
||||
local.updateFlags(LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
if (toDownload.size >= MAX_MULTIGET_RESOURCES || url == null) {
|
||||
while (toDownload.size > 0) {
|
||||
val bunch = LinkedList<HttpUrl>()
|
||||
toDownload.drainTo(bunch, MAX_MULTIGET_RESOURCES)
|
||||
launch {
|
||||
downloadRemote(bunch)
|
||||
}
|
||||
}
|
||||
|
||||
synchronized(processor) {
|
||||
if (toDownload.size >= MAX_MULTIGET_RESOURCES)
|
||||
// download another bunch of MAX_MULTIGET_RESOURCES resources
|
||||
downloadBunch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} else if (response.status?.code == HttpURLConnection.HTTP_NOT_FOUND) {
|
||||
// collection sync: resource has been deleted on remote server
|
||||
results += processor.submit {
|
||||
useLocal(localCollection.findByName(name)) { local ->
|
||||
Logger.log.info("$name has been deleted on server, deleting locally")
|
||||
local?.delete()
|
||||
nDeleted.incrementAndGet()
|
||||
coroutineScope {
|
||||
listRemote { response, relation ->
|
||||
// ignore non-members
|
||||
if (relation != Response.HrefRelation.MEMBER)
|
||||
return@listRemote
|
||||
|
||||
// ignore collections
|
||||
if (response[at.bitfire.dav4jvm.property.ResourceType::class.java]?.types?.contains(at.bitfire.dav4jvm.property.ResourceType.COLLECTION) == true)
|
||||
return@listRemote
|
||||
|
||||
val name = response.hrefName()
|
||||
|
||||
if (response.isSuccess()) {
|
||||
Logger.log.fine("Found remote resource: $name")
|
||||
|
||||
launch {
|
||||
localExceptionContext(localCollection.findByName(name)) { local ->
|
||||
if (local == null) {
|
||||
Logger.log.info("$name has been added remotely, queueing download")
|
||||
download(response.href)
|
||||
nInserted.incrementAndGet()
|
||||
} else {
|
||||
val localETag = local.eTag
|
||||
val remoteETag = response[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Server didn't provide ETag")
|
||||
if (localETag == remoteETag) {
|
||||
Logger.log.info("$name has not been changed on server (ETag still $remoteETag)")
|
||||
nSkipped.incrementAndGet()
|
||||
} else {
|
||||
Logger.log.info("$name has been changed on server (current ETag=$remoteETag, last known ETag=$localETag)")
|
||||
download(response.href)
|
||||
nUpdated.incrementAndGet()
|
||||
}
|
||||
|
||||
// mark as remotely present, so that this resource won't be deleted at the end
|
||||
local.updateFlags(LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} else if (response.status?.code == HttpURLConnection.HTTP_NOT_FOUND) {
|
||||
// collection sync: resource has been deleted on remote server
|
||||
launch {
|
||||
localExceptionContext(localCollection.findByName(name)) { local ->
|
||||
Logger.log.info("$name has been deleted on server, deleting locally")
|
||||
local?.delete()
|
||||
nDeleted.incrementAndGet()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check already available results for exceptions so that they don't become too many
|
||||
checkResults(results)
|
||||
// download remaining resources
|
||||
download(null)
|
||||
}
|
||||
|
||||
// process remaining responses
|
||||
processor.shutdown()
|
||||
processor.awaitTermination(5, TimeUnit.MINUTES)
|
||||
|
||||
// download remaining resources
|
||||
if (toDownload.isNotEmpty())
|
||||
downloadBunch()
|
||||
|
||||
// signal end of queue and wait for download thread
|
||||
downloader.shutdown()
|
||||
downloader.awaitTermination(5, TimeUnit.MINUTES)
|
||||
|
||||
// check remaining results for exceptions
|
||||
checkResults(results)
|
||||
|
||||
// update sync stats
|
||||
with(syncResult.stats) {
|
||||
numInserts += nInserted.get()
|
||||
@@ -553,6 +586,24 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
return Pair(syncToken!!, furtherResults)
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads and processes resources, given as a list of URLs. Will be called with a list
|
||||
* of changed/new remote resources.
|
||||
*
|
||||
* Implementations should not use GET to fetch single resources, but always multi-get, even
|
||||
* for single resources for these reasons:
|
||||
*
|
||||
* 1. GET can only be used without HTTP compression, because it may change the ETag.
|
||||
* multi-get sends the ETag in the XML body, so there's no problem with compression.
|
||||
* 2. Some servers are wrongly configured to suppress the ETag header in the response.
|
||||
* With multi-get, the ETag is in the XML body, so it won't be affected by that.
|
||||
* 3. If there are two methods to download resources (GET and multi-get), both methods
|
||||
* have to be implemented, tested and maintained. Given that multi-get is required
|
||||
* in any case, it's better to have only one method.
|
||||
* 4. For users, it's strange behavior when DAVx5 can download multiple remote changes,
|
||||
* but not a single one (or vice versa). So only one method is more user-friendly.
|
||||
* 5. March 2020: iCloud now crashes with HTTP 500 upon CardDAV GET requests.
|
||||
*/
|
||||
protected abstract fun downloadRemote(bunch: List<HttpUrl>)
|
||||
|
||||
/**
|
||||
@@ -563,10 +614,10 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
* Used together with [resetPresentRemotely] when a full listing has been received from
|
||||
* the server to locally delete resources which are not present remotely (anymore).
|
||||
*/
|
||||
protected open fun deleteNotPresentRemotely(): Int {
|
||||
protected open fun deleteNotPresentRemotely() {
|
||||
val removed = localCollection.removeNotDirtyMarked(0)
|
||||
Logger.log.info("Removed $removed local resources which are not present on the server anymore")
|
||||
return removed
|
||||
syncResult.stats.numDeletes += removed
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -577,17 +628,6 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
|
||||
// sync helpers
|
||||
|
||||
/**
|
||||
* Throws an [InterruptedException] if the current thread has been interrupted,
|
||||
* most probably because synchronization was cancelled by the user.
|
||||
*
|
||||
* @throws InterruptedException (which will be caught by [performSync])
|
||||
* */
|
||||
protected fun abortIfCancelled() {
|
||||
if (Thread.interrupted())
|
||||
throw InterruptedException("Sync was cancelled")
|
||||
}
|
||||
|
||||
protected fun syncState(dav: Response) =
|
||||
dav[SyncToken::class.java]?.token?.let {
|
||||
SyncState(SyncState.Type.SYNC_TOKEN, it)
|
||||
@@ -643,25 +683,16 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
val contentIntent: Intent
|
||||
var viewItemAction: NotificationCompat.Action? = null
|
||||
if (e is UnauthorizedException) {
|
||||
contentIntent = Intent(context, AccountSettingsActivity::class.java)
|
||||
contentIntent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account)
|
||||
contentIntent = Intent(context, SettingsActivity::class.java)
|
||||
contentIntent.putExtra(SettingsActivity.EXTRA_ACCOUNT,
|
||||
if (authority == ContactsContract.AUTHORITY)
|
||||
mainAccount
|
||||
else
|
||||
account)
|
||||
} else {
|
||||
contentIntent = Intent(context, DebugInfoActivity::class.java)
|
||||
contentIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
|
||||
|
||||
contentIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account)
|
||||
contentIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority)
|
||||
|
||||
// use current local/remote resource
|
||||
if (local != null) {
|
||||
// pass local resource info to debug info
|
||||
contentIntent.putExtra(DebugInfoActivity.KEY_LOCAL_RESOURCE, local.toString())
|
||||
|
||||
// generate "view item" action
|
||||
contentIntent = buildDebugInfoIntent(e, local, remote)
|
||||
if (local != null)
|
||||
viewItemAction = buildViewItemAction(local)
|
||||
}
|
||||
if (remote != null)
|
||||
contentIntent.putExtra(DebugInfoActivity.KEY_REMOTE_RESOURCE, remote.toString())
|
||||
}
|
||||
|
||||
// to make the PendingIntent unique
|
||||
@@ -678,7 +709,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
}
|
||||
|
||||
val builder = NotificationUtils.newBuilder(context, channel)
|
||||
builder .setSmallIcon(R.drawable.ic_sync_error_notification)
|
||||
builder .setSmallIcon(R.drawable.ic_sync_problem_notify)
|
||||
.setContentTitle(localCollection.title)
|
||||
.setContentText(message)
|
||||
.setStyle(NotificationCompat.BigTextStyle(builder).bigText(message))
|
||||
@@ -693,6 +724,19 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
notificationManager.notify(notificationTag, NotificationUtils.NOTIFY_SYNC_ERROR, builder.build())
|
||||
}
|
||||
|
||||
private fun buildDebugInfoIntent(e: Throwable, local: ResourceType?, remote: HttpUrl?) =
|
||||
Intent(context, DebugInfoActivity::class.java).apply {
|
||||
putExtra(DebugInfoActivity.KEY_ACCOUNT, account)
|
||||
putExtra(DebugInfoActivity.KEY_AUTHORITY, authority)
|
||||
putExtra(DebugInfoActivity.KEY_THROWABLE, e)
|
||||
|
||||
// pass current local/remote resource
|
||||
if (local != null)
|
||||
putExtra(DebugInfoActivity.KEY_LOCAL_RESOURCE, local.toString())
|
||||
if (remote != null)
|
||||
putExtra(DebugInfoActivity.KEY_REMOTE_RESOURCE, remote.toString())
|
||||
}
|
||||
|
||||
private fun buildRetryAction(): NotificationCompat.Action {
|
||||
val retryIntent = Intent(context, DavService::class.java)
|
||||
retryIntent.action = DavService.ACTION_FORCE_SYNC
|
||||
@@ -740,6 +784,8 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
null
|
||||
}
|
||||
|
||||
|
||||
@Deprecated("Use Kotlin coroutines instead")
|
||||
fun checkResults(results: MutableCollection<Future<*>>) {
|
||||
val iter = results.iterator()
|
||||
while (iter.hasNext()) {
|
||||
@@ -755,7 +801,24 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
}
|
||||
}
|
||||
|
||||
protected fun<T: ResourceType?, R> useLocal(local: T, body: (T) -> R): R {
|
||||
protected fun notifyInvalidResource(e: Throwable, fileName: String) {
|
||||
val intent = buildDebugInfoIntent(e, null, collectionURL.resolve(fileName))
|
||||
|
||||
val builder = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_WARNINGS)
|
||||
builder .setSmallIcon(R.drawable.ic_warning_notify)
|
||||
.setContentTitle(notifyInvalidResourceTitle())
|
||||
.setContentText(context.getString(R.string.sync_invalid_resources_ignoring))
|
||||
.setSubText(mainAccount.name)
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.priority = NotificationCompat.PRIORITY_LOW
|
||||
notificationManager.notify(notificationTag, NotificationUtils.NOTIFY_INVALID_RESOURCE, builder.build())
|
||||
}
|
||||
|
||||
protected abstract fun notifyInvalidResourceTitle(): String
|
||||
|
||||
protected fun<T: ResourceType?, R> localExceptionContext(local: T, body: (T) -> R): R {
|
||||
try {
|
||||
return body(local)
|
||||
} catch (e: ContextedException) {
|
||||
@@ -769,7 +832,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
}
|
||||
}
|
||||
|
||||
protected fun<T: DavResource, R> useRemote(remote: T, body: (T) -> R): R {
|
||||
protected fun<T: DavResource, R> remoteExceptionContext(remote: T, body: (T) -> R): R {
|
||||
try {
|
||||
return body(remote)
|
||||
} catch (e: ContextedException) {
|
||||
@@ -780,7 +843,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
}
|
||||
}
|
||||
|
||||
protected fun<T> useRemote(remote: Response, body: (Response) -> T): T {
|
||||
protected fun<T> responseExceptionContext(remote: Response, body: (Response) -> T): T {
|
||||
try {
|
||||
return body(remote)
|
||||
} catch (e: ContextedException) {
|
||||
@@ -791,8 +854,8 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
}
|
||||
}
|
||||
|
||||
protected fun<R> useRemoteCollection(body: (RemoteType) -> R) =
|
||||
useRemote(davCollection, body)
|
||||
protected fun<R> remoteExceptionContext(body: (RemoteType) -> R) =
|
||||
remoteExceptionContext(davCollection, body)
|
||||
|
||||
private fun unwrapExceptions(body: () -> Unit, handler: (e: Throwable, local: ResourceType?, remote: HttpUrl?) -> Unit) {
|
||||
var ex: Throwable? = null
|
||||
|
||||