mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2026-08-07 05:44:54 -04:00
Compare commits
556
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
+5
-4
@@ -1,4 +1,4 @@
|
||||
image: registry.gitlab.com/bitfireat/davdroid:latest
|
||||
image: registry.gitlab.com/bitfireat/docker-android-emulator:latest
|
||||
|
||||
before_script:
|
||||
- git submodule update --init --recursive
|
||||
@@ -7,12 +7,13 @@ before_script:
|
||||
cache:
|
||||
paths:
|
||||
- .gradle/
|
||||
- apk/
|
||||
|
||||
test:
|
||||
tags:
|
||||
- privileged
|
||||
script:
|
||||
- (cd /sdk/emulator; ./emulator @test -no-audio -no-window & wait-for-emulator.sh)
|
||||
- ./gradlew check mergeAndroidReports
|
||||
- start-emulator.sh
|
||||
- ./gradlew app:check app:connectedCheck
|
||||
artifacts:
|
||||
paths:
|
||||
- app/build/outputs/lint-results-debug.html
|
||||
|
||||
+3
-6
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -1,40 +1,37 @@
|
||||

|
||||
|
||||
[](https://gitlab.com/bitfireAT/davdroid/commits/master-ose)
|
||||
|
||||
|
||||
DAVdroid
|
||||
DAVx⁵
|
||||
========
|
||||
|
||||
Please see the [DAVdroid Web site](https://www.davdroid.com) for
|
||||
comprehensive information about DAVdroid.
|
||||
Please see the [DAVx⁵ Web site](https://www.davx5.com) for
|
||||
comprehensive information about DAVx⁵.
|
||||
|
||||
DAVdroid is licensed under the [GPLv3 License](LICENSE).
|
||||
DAVx⁵ is licensed under the [GPLv3 License](LICENSE).
|
||||
|
||||
News and updates: [@davdroidapp](https://twitter.com/davdroidapp) on Twitter
|
||||
News and updates: [@davx5app](https://twitter.com/davx5app) on Twitter
|
||||
|
||||
Help and discussion: [DAVdroid forums](https://www.davdroid.com/forums/)
|
||||
Help, discussion, feature requests, bug reports and "issues": [DAVx⁵ forums](https://www.davx5.com/forums)
|
||||
|
||||
**If you want to support DAVdroid, please consider [donating to DAVdroid](https://www.davdroid.com/donate/)
|
||||
or [purchasing it](https://www.davdroid.com/download/).**
|
||||
**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/davdroid/dokka/app/
|
||||
Generated KDoc: https://bitfireAT.gitlab.io/davx5-ose/dokka/app/
|
||||
|
||||
Parts of DAVdroid have been outsourced into these libraries:
|
||||
Parts of DAVx⁵ have been outsourced into these libraries:
|
||||
|
||||
* [cert4android](https://gitlab.com/bitfireAT/cert4android) – custom certificate management
|
||||
* [dav4android](https://gitlab.com/bitfireAT/dav4android) – WebDAV/CalDav/CardDAV framework
|
||||
* [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
|
||||
* [vcard4android](https://gitlab.com/bitfireAT/vcard4android) – vCard processing and Contacts Provider access
|
||||
|
||||
|
||||
USED THIRD-PARTY LIBRARIES
|
||||
==========================
|
||||
|
||||
Those libraries are used by DAVdroid (alphabetically):
|
||||
Those libraries are used by DAVx⁵ (alphabetically):
|
||||
|
||||
* [Ambilwarna](https://github.com/yukuku/ambilwarna) – [Apache License, Version 2.0](https://github.com/yukuku/ambilwarna/blob/master/LICENSE)
|
||||
* [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)
|
||||
* [Project Lombok](http://projectlombok.org/) – [MIT License](http://opensource.org/licenses/mit-license.php)
|
||||
+99
-48
@@ -7,94 +7,145 @@
|
||||
*/
|
||||
|
||||
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'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'org.jetbrains.dokka'
|
||||
|
||||
android {
|
||||
compileSdkVersion 27
|
||||
buildToolsVersion '27.0.3'
|
||||
compileSdkVersion 29
|
||||
buildToolsVersion '29.0.2'
|
||||
|
||||
defaultConfig {
|
||||
applicationId "at.bitfire.davdroid"
|
||||
|
||||
versionCode 211
|
||||
versionCode 301000012
|
||||
buildConfigField "long", "buildTime", System.currentTimeMillis() + "L"
|
||||
buildConfigField "boolean", "customCerts", "true"
|
||||
|
||||
minSdkVersion 19 // Android 4.4
|
||||
targetSdkVersion 27 // Android 8.1
|
||||
minSdkVersion 21 // Android 5.0
|
||||
targetSdkVersion 29 // Android 10.0
|
||||
|
||||
// when using this, make sure that notification icons are real bitmaps
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
buildConfigField "String", "userAgent", "\"DAVx5\""
|
||||
|
||||
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 {
|
||||
versionName "1.11-rc1-ose"
|
||||
|
||||
buildConfigField "boolean", "customCerts", "true"
|
||||
versionName "3.1-ose"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
minifyEnabled false
|
||||
// minifyEnabled enables R8 and thus desugaring (which is needed for working debug builds)
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules-debug.pro'
|
||||
}
|
||||
release {
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules-release.pro'
|
||||
|
||||
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 {
|
||||
compile project(':cert4android')
|
||||
compile project(':dav4android')
|
||||
compile project(':ical4android')
|
||||
compile project(':vcard4android')
|
||||
implementation project(':cert4android')
|
||||
implementation project(':ical4android')
|
||||
implementation project(':vcard4android')
|
||||
|
||||
compile "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.5'
|
||||
|
||||
compile 'com.android.support:appcompat-v7:27.1.0'
|
||||
compile 'com.android.support:cardview-v7:27.1.0'
|
||||
compile 'com.android.support:design:27.1.0'
|
||||
compile 'com.android.support:preference-v14:27.1.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.2.0-rc01'
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta6'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.2.4'
|
||||
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-rc01'
|
||||
implementation 'com.google.android:flexbox:1.1.0'
|
||||
implementation 'com.google.android.material:material:1.2.0-beta01'
|
||||
|
||||
compile 'com.github.yukuku:ambilwarna:2.0.1'
|
||||
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"
|
||||
|
||||
compile 'com.squareup.okhttp3:logging-interceptor:3.10.0'
|
||||
compile 'commons-io:commons-io:2.6'
|
||||
compile 'dnsjava:dnsjava:2.1.8'
|
||||
compile 'org.apache.commons:commons-lang3:3.6'
|
||||
compile 'org.apache.commons:commons-collections4:4.1'
|
||||
implementation 'com.jaredrummler:colorpicker:1.1.0'
|
||||
implementation "com.github.AppIntro:AppIntro:${versions.appIntro}"
|
||||
implementation "com.gitlab.bitfireAT:dav4jvm:${versions.dav4jvm}"
|
||||
implementation "com.mikepenz:aboutlibraries:${versions.aboutLibraries}"
|
||||
implementation "com.squareup.okhttp3:okhttp:${versions.okhttp}"
|
||||
implementation "com.squareup.okhttp3:okhttp-brotli:${versions.okhttp}"
|
||||
implementation "com.squareup.okhttp3:logging-interceptor:${versions.okhttp}"
|
||||
implementation 'commons-io:commons-io:2.6'
|
||||
//noinspection GradleDependency - dnsjava 3+ needs Java 8/Android 7
|
||||
implementation 'dnsjava:dnsjava:2.1.9'
|
||||
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
|
||||
androidTestCompile 'com.android.support.test:runner:1.0.1'
|
||||
androidTestCompile 'com.android.support.test:rules:1.0.1'
|
||||
androidTestCompile 'junit:junit:4.12'
|
||||
androidTestCompile 'com.squareup.okhttp3:mockwebserver:3.10.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}"
|
||||
|
||||
testCompile 'junit:junit:4.12'
|
||||
testCompile 'com.squareup.okhttp3:mockwebserver:3.10.0'
|
||||
testImplementation 'junit:junit:4.13'
|
||||
testImplementation "com.squareup.okhttp3:mockwebserver:${versions.okhttp}"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
# R8 usage for DAVx⁵:
|
||||
# shrinking no (only in release builds)
|
||||
# optimization yes (on by R8 defaults)
|
||||
# obfuscation no (open-source)
|
||||
|
||||
-dontobfuscate
|
||||
-dontshrink
|
||||
@@ -0,0 +1,25 @@
|
||||
|
||||
# R8 usage for DAVx⁵:
|
||||
# shrinking yes (only in release builds)
|
||||
# optimization yes (on by R8 defaults)
|
||||
# obfuscation no (open-source)
|
||||
|
||||
-dontobfuscate
|
||||
|
||||
# okhttp
|
||||
-keepclassmembers class okhttp3.internal.Util.** { *; }
|
||||
|
||||
# ez-vcard
|
||||
-keep class ezvcard.property.** { *; } # keep all vCard properties (created at runtime)
|
||||
|
||||
# ical4j
|
||||
-keep class net.fortuna.ical4j.** { *; } # keep all model classes (properties/factories, created at runtime)
|
||||
|
||||
# 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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
File renamed without changes.
+56
-47
@@ -8,12 +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.Companion.toHttpUrl
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
@@ -21,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()
|
||||
@@ -39,7 +38,7 @@ class CollectionInfoTest {
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testFromDavResource() {
|
||||
fun testFromDavResponseAddressBook() {
|
||||
// r/w address book
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
@@ -54,14 +53,24 @@ class CollectionInfoTest {
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
var dav = DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
dav.propfind(0, ResourceType.NAME)
|
||||
var info = CollectionInfo(dav)
|
||||
assertEquals(CollectionInfo.Type.ADDRESS_BOOK, info.type)
|
||||
assertFalse(info.readOnly)
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(Collection.TYPE_ADDRESSBOOK, info.type)
|
||||
assertTrue(info.privWriteContent)
|
||||
assertTrue(info.privUnbind)
|
||||
assertNull(info.supportsVEVENT)
|
||||
assertNull(info.supportsVTODO)
|
||||
assertNull(info.supportsVJOURNAL)
|
||||
assertEquals("My Contacts", info.displayName)
|
||||
assertEquals("My Contacts Description", info.description)
|
||||
}
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testFromDavResponseCalendar() {
|
||||
// read-only calendar, no display name
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
@@ -78,48 +87,48 @@ class CollectionInfoTest {
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
dav = DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
dav.propfind(0, ResourceType.NAME)
|
||||
info = CollectionInfo(dav)
|
||||
assertEquals(CollectionInfo.Type.CALENDAR, info.type)
|
||||
assertTrue(info.readOnly)
|
||||
lateinit var info: Collection
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
info = Collection.fromDavResponse(response) ?: throw IllegalArgumentException()
|
||||
}
|
||||
assertEquals(Collection.TYPE_CALENDAR, info.type)
|
||||
assertFalse(info.privWriteContent)
|
||||
assertFalse(info.privUnbind)
|
||||
assertNull(info.displayName)
|
||||
assertEquals("My Calendar", info.description)
|
||||
assertEquals(0xFFFF0000.toInt(), info.color)
|
||||
assertEquals("tzdata", info.timeZone)
|
||||
assertTrue(info.supportsVEVENT)
|
||||
assertTrue(info.supportsVTODO)
|
||||
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("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))
|
||||
}
|
||||
|
||||
}
|
||||
+10
-11
@@ -8,32 +8,31 @@
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import at.bitfire.davdroid.App
|
||||
import junit.framework.Assert.assertEquals
|
||||
import junit.framework.Assert.assertFalse
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultsProviderTest {
|
||||
class DefaultsSettingsProviderTest {
|
||||
|
||||
private val provider: Provider = DefaultsProvider()
|
||||
private val provider: SettingsProvider = DefaultsProvider()
|
||||
|
||||
@Test
|
||||
fun testHas() {
|
||||
assertEquals(Pair(false, true), provider.has("notExisting"))
|
||||
assertEquals(Pair(true, true), provider.has(App.OVERRIDE_PROXY))
|
||||
assertEquals(Pair(true, true), provider.has(Settings.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))
|
||||
assertEquals(Pair("localhost", true), provider.getString(Settings.OVERRIDE_PROXY_HOST))
|
||||
assertEquals(Pair(8118, true), provider.getInt(Settings.OVERRIDE_PROXY_PORT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPutRemove() {
|
||||
assertEquals(Pair(false, true), provider.isWritable(App.OVERRIDE_PROXY))
|
||||
assertFalse(provider.putBoolean(App.OVERRIDE_PROXY, true))
|
||||
assertFalse(provider.remove(App.OVERRIDE_PROXY))
|
||||
assertEquals(Pair(false, true), provider.isWritable(Settings.OVERRIDE_PROXY))
|
||||
assertFalse(provider.putBoolean(Settings.OVERRIDE_PROXY, true))
|
||||
assertFalse(provider.remove(Settings.OVERRIDE_PROXY))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,37 +8,27 @@
|
||||
|
||||
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 androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class SettingsTest {
|
||||
|
||||
lateinit var settings: Settings.Stub
|
||||
lateinit var settings: Settings
|
||||
|
||||
@Before
|
||||
fun init() {
|
||||
InstrumentationRegistry.getContext().isRestricted
|
||||
settings = Settings.getInstance(getTargetContext())!!
|
||||
fun initialize() {
|
||||
settings = Settings.getInstance(InstrumentationRegistry.getInstrumentation().targetContext)
|
||||
}
|
||||
|
||||
@After
|
||||
fun shutdown() {
|
||||
settings.close()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testHas() {
|
||||
assertFalse(settings.has("notExisting"))
|
||||
|
||||
// provided by DefaultsProvider
|
||||
assertTrue(settings.has(App.OVERRIDE_PROXY))
|
||||
assertTrue(settings.has(Settings.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()
|
||||
}
|
||||
|
||||
@@ -68,32 +70,25 @@ class DavResourceFinderTest {
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testRememberIfAddressBookOrHomeset() {
|
||||
// before dav.propfind(), no info is available
|
||||
var dav = DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL))
|
||||
ServiceInfo().let { info ->
|
||||
finder.rememberIfAddressBookOrHomeset(dav, info)
|
||||
assertEquals(0, info.collections.size)
|
||||
assertEquals(0, info.homeSets.size)
|
||||
}
|
||||
|
||||
// recognize home set
|
||||
dav.propfind(0, AddressbookHomeSet.NAME)
|
||||
ServiceInfo().let { info ->
|
||||
finder.rememberIfAddressBookOrHomeset(dav, info)
|
||||
assertEquals(0, info.collections.size)
|
||||
assertEquals(1, info.homeSets.size)
|
||||
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET/").uri(), info.homeSets.first())
|
||||
var info = ServiceInfo()
|
||||
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL))
|
||||
.propfind(0, AddressbookHomeSet.NAME) { response, _ ->
|
||||
finder.scanCardDavResponse(response, info)
|
||||
}
|
||||
assertEquals(0, info.collections.size)
|
||||
assertEquals(1, info.homeSets.size)
|
||||
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET/"), info.homeSets.first())
|
||||
|
||||
// recognize address book
|
||||
dav = DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK))
|
||||
dav.propfind(0, ResourceType.NAME)
|
||||
ServiceInfo().let { info ->
|
||||
finder.rememberIfAddressBookOrHomeset(dav, info)
|
||||
assertEquals(1, info.collections.size)
|
||||
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/").uri(), info.collections.keys.first())
|
||||
assertEquals(0, info.homeSets.size)
|
||||
info = ServiceInfo()
|
||||
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK))
|
||||
.propfind(0, ResourceType.NAME) { response, _ ->
|
||||
finder.scanCardDavResponse(response, info)
|
||||
}
|
||||
assertEquals(1, info.collections.size)
|
||||
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), info.collections.keys.first())
|
||||
assertEquals(0, info.homeSets.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -117,33 +112,43 @@ class DavResourceFinderTest {
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV))
|
||||
|
||||
assertEquals(
|
||||
server.url(PATH_CALDAV + SUBPATH_PRINCIPAL).uri(),
|
||||
server.url(PATH_CALDAV + SUBPATH_PRINCIPAL),
|
||||
finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV)
|
||||
)
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV))
|
||||
|
||||
assertEquals(
|
||||
server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL).uri(),
|
||||
server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL),
|
||||
finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV)
|
||||
)
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testQueryEmailAddress() {
|
||||
var info = ServiceInfo()
|
||||
assertArrayEquals(
|
||||
arrayOf("email1@example.com", "email2@example.com"),
|
||||
finder.queryEmailAddress(server.url(PATH_CALDAV + SUBPATH_PRINCIPAL)).toTypedArray()
|
||||
)
|
||||
assertTrue(finder.queryEmailAddress(server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL)).isEmpty())
|
||||
}
|
||||
|
||||
|
||||
// mock server
|
||||
|
||||
class TestDispatcher: Dispatcher() {
|
||||
|
||||
override fun dispatch(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"
|
||||
@@ -154,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,
|
||||
@@ -172,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>")
|
||||
|
||||
@@ -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-----
|
||||
Binary file not shown.
@@ -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"
|
||||
@@ -71,14 +60,20 @@
|
||||
|
||||
<activity
|
||||
android:name=".ui.AboutActivity"
|
||||
android:label="@string/app_name"
|
||||
android:label="@string/navigation_drawer_about"
|
||||
android:theme="@style/AppTheme.NoActionBar"
|
||||
android:parentActivityName=".ui.AccountsActivity"/>
|
||||
|
||||
<activity
|
||||
android:name=".ui.AppSettingsActivity"
|
||||
android:label="@string/app_settings"
|
||||
android:parentActivityName=".ui.AccountsActivity"/>
|
||||
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,153 +0,0 @@
|
||||
<h3>Apache License, Version 2.0, January 2004</h3>
|
||||
<p><a href="http://www.apache.org/licenses/">http://www.apache.org/licenses/</a> </p>
|
||||
<p>TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION</p>
|
||||
<p><strong><a name="definitions">1. Definitions</a></strong>.</p>
|
||||
<p>"License" shall mean the terms and conditions for use, reproduction, and
|
||||
distribution as defined by Sections 1 through 9 of this document.</p>
|
||||
<p>"Licensor" shall mean the copyright owner or entity authorized by the
|
||||
copyright owner that is granting the License.</p>
|
||||
<p>"Legal Entity" shall mean the union of the acting entity and all other
|
||||
entities that control, are controlled by, or are under common control with
|
||||
that entity. For the purposes of this definition, "control" means (i) the
|
||||
power, direct or indirect, to cause the direction or management of such
|
||||
entity, whether by contract or otherwise, or (ii) ownership of fifty
|
||||
percent (50%) or more of the outstanding shares, or (iii) beneficial
|
||||
ownership of such entity.</p>
|
||||
<p>"You" (or "Your") shall mean an individual or Legal Entity exercising
|
||||
permissions granted by this License.</p>
|
||||
<p>"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation source,
|
||||
and configuration files.</p>
|
||||
<p>"Object" form shall mean any form resulting from mechanical transformation
|
||||
or translation of a Source form, including but not limited to compiled
|
||||
object code, generated documentation, and conversions to other media types.</p>
|
||||
<p>"Work" shall mean the work of authorship, whether in Source or Object form,
|
||||
made available under the License, as indicated by a copyright notice that
|
||||
is included in or attached to the work (an example is provided in the
|
||||
Appendix below).</p>
|
||||
<p>"Derivative Works" shall mean any work, whether in Source or Object form,
|
||||
that is based on (or derived from) the Work and for which the editorial
|
||||
revisions, annotations, elaborations, or other modifications represent, as
|
||||
a whole, an original work of authorship. For the purposes of this License,
|
||||
Derivative Works shall not include works that remain separable from, or
|
||||
merely link (or bind by name) to the interfaces of, the Work and Derivative
|
||||
Works thereof.</p>
|
||||
<p>"Contribution" shall mean any work of authorship, including the original
|
||||
version of the Work and any modifications or additions to that Work or
|
||||
Derivative Works thereof, that is intentionally submitted to Licensor for
|
||||
inclusion in the Work by the copyright owner or by an individual or Legal
|
||||
Entity authorized to submit on behalf of the copyright owner. For the
|
||||
purposes of this definition, "submitted" means any form of electronic,
|
||||
verbal, or written communication sent to the Licensor or its
|
||||
representatives, including but not limited to communication on electronic
|
||||
mailing lists, source code control systems, and issue tracking systems that
|
||||
are managed by, or on behalf of, the Licensor for the purpose of discussing
|
||||
and improving the Work, but excluding communication that is conspicuously
|
||||
marked or otherwise designated in writing by the copyright owner as "Not a
|
||||
Contribution."</p>
|
||||
<p>"Contributor" shall mean Licensor and any individual or Legal Entity on
|
||||
behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.</p>
|
||||
<p><strong><a name="copyright">2. Grant of Copyright License</a></strong>. Subject to the
|
||||
terms and conditions of this License, each Contributor hereby grants to You
|
||||
a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of, publicly
|
||||
display, publicly perform, sublicense, and distribute the Work and such
|
||||
Derivative Works in Source or Object form.</p>
|
||||
<p><strong><a name="patent">3. Grant of Patent License</a></strong>. Subject to the terms
|
||||
and conditions of this License, each Contributor hereby grants to You a
|
||||
perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made, use,
|
||||
offer to sell, sell, import, and otherwise transfer the Work, where such
|
||||
license applies only to those patent claims licensable by such Contributor
|
||||
that are necessarily infringed by their Contribution(s) alone or by
|
||||
combination of their Contribution(s) with the Work to which such
|
||||
Contribution(s) was submitted. If You institute patent litigation against
|
||||
any entity (including a cross-claim or counterclaim in a lawsuit) alleging
|
||||
that the Work or a Contribution incorporated within the Work constitutes
|
||||
direct or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate as of the
|
||||
date such litigation is filed.</p>
|
||||
<p><strong><a name="redistribution">4. Redistribution</a></strong>. You may reproduce and
|
||||
distribute copies of the Work or Derivative Works thereof in any medium,
|
||||
with or without modifications, and in Source or Object form, provided that
|
||||
You meet the following conditions:</p>
|
||||
|
||||
<p>a. You must give any other recipients of the Work or Derivative Works a
|
||||
copy of this License; and</p>
|
||||
|
||||
<p>b. You must cause any modified files to carry prominent notices stating
|
||||
that You changed the files; and</p>
|
||||
|
||||
<p>c. You must retain, in the Source form of any Derivative Works that You
|
||||
distribute, all copyright, patent, trademark, and attribution notices from
|
||||
the Source form of the Work, excluding those notices that do not pertain to
|
||||
any part of the Derivative Works; and</p>
|
||||
|
||||
<p>d. If the Work includes a "NOTICE" text file as part of its distribution,
|
||||
then any Derivative Works that You distribute must include a readable copy
|
||||
of the attribution notices contained within such NOTICE file, excluding
|
||||
those notices that do not pertain to any part of the Derivative Works, in
|
||||
at least one of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or documentation,
|
||||
if provided along with the Derivative Works; or, within a display generated
|
||||
by the Derivative Works, if and wherever such third-party notices normally
|
||||
appear. The contents of the NOTICE file are for informational purposes only
|
||||
and do not modify the License. You may add Your own attribution notices
|
||||
within Derivative Works that You distribute, alongside or as an addendum to
|
||||
the NOTICE text from the Work, provided that such additional attribution
|
||||
notices cannot be construed as modifying the License.
|
||||
<br/>
|
||||
<br/>
|
||||
You may add Your own copyright statement to Your modifications and may
|
||||
provide additional or different license terms and conditions for use,
|
||||
reproduction, or distribution of Your modifications, or for any such
|
||||
Derivative Works as a whole, provided Your use, reproduction, and
|
||||
distribution of the Work otherwise complies with the conditions stated in
|
||||
this License.
|
||||
</p>
|
||||
|
||||
<p><strong><a name="contributions">5. Submission of Contributions</a></strong>. Unless You
|
||||
explicitly state otherwise, any Contribution intentionally submitted for
|
||||
inclusion in the Work by You to the Licensor shall be under the terms and
|
||||
conditions of this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify the
|
||||
terms of any separate license agreement you may have executed with Licensor
|
||||
regarding such Contributions.</p>
|
||||
<p><strong><a name="trademarks">6. Trademarks</a></strong>. This License does not grant
|
||||
permission to use the trade names, trademarks, service marks, or product
|
||||
names of the Licensor, except as required for reasonable and customary use
|
||||
in describing the origin of the Work and reproducing the content of the
|
||||
NOTICE file.</p>
|
||||
<p><strong><a name="no-warranty">7. Disclaimer of Warranty</a></strong>. Unless required by
|
||||
applicable law or agreed to in writing, Licensor provides the Work (and
|
||||
each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT
|
||||
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including,
|
||||
without limitation, any warranties or conditions of TITLE,
|
||||
NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You
|
||||
are solely responsible for determining the appropriateness of using or
|
||||
redistributing the Work and assume any risks associated with Your exercise
|
||||
of permissions under this License.</p>
|
||||
<p><strong><a name="no-liability">8. Limitation of Liability</a></strong>. In no event and
|
||||
under no legal theory, whether in tort (including negligence), contract, or
|
||||
otherwise, unless required by applicable law (such as deliberate and
|
||||
grossly negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a result
|
||||
of this License or out of the use or inability to use the Work (including
|
||||
but not limited to damages for loss of goodwill, work stoppage, computer
|
||||
failure or malfunction, or any and all other commercial damages or losses),
|
||||
even if such Contributor has been advised of the possibility of such
|
||||
damages.</p>
|
||||
<p><strong><a name="additional">9. Accepting Warranty or Additional Liability</a></strong>.
|
||||
While redistributing the Work or Derivative Works thereof, You may choose
|
||||
to offer, and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this License.
|
||||
However, in accepting such obligations, You may act only on Your own behalf
|
||||
and on Your sole responsibility, not on behalf of any other Contributor,
|
||||
and only if You agree to indemnify, defend, and hold each Contributor
|
||||
harmless for any liability incurred by, or claims asserted against, such
|
||||
Contributor by reason of your accepting any such warranty or additional
|
||||
liability.</p>
|
||||
|
||||
<p>END OF TERMS AND CONDITIONS</p>
|
||||
@@ -1,28 +0,0 @@
|
||||
<h3>BSD License (3-clause)</h3>
|
||||
|
||||
<p>Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:</p>
|
||||
|
||||
<p>o Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.</p>
|
||||
|
||||
<p>o Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.</p>
|
||||
|
||||
<p>o Neither the name of Ben Fortuna nor the names of any other contributors
|
||||
may be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.</p>
|
||||
|
||||
<p>THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||||
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.</p>
|
||||
@@ -1,23 +0,0 @@
|
||||
<h3>BSD License</h3>
|
||||
|
||||
<p>Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:</p>
|
||||
|
||||
<p>1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.</p>
|
||||
|
||||
<p>2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.</p>
|
||||
|
||||
<p>THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
||||
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.</p>
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<p style="text-align: center;">Version 3, 29 June 2007</p>
|
||||
|
||||
<p>Copyright © 2007 Free Software Foundation, Inc.
|
||||
<<a href="http://fsf.org/">http://fsf.org/</a>></p><p>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.</p>
|
||||
<<a href="http://fsf.org/">http://fsf.org/</a>></p><p>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.</p>
|
||||
|
||||
<h3><a name="preamble"></a>Preamble</h3>
|
||||
|
||||
@@ -42,16 +42,16 @@ know their rights.</p>
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.</p>
|
||||
|
||||
<p>For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
<p>For the developers\' and authors\' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users\' and
|
||||
authors\' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.</p>
|
||||
|
||||
<p>Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
protecting users\' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
@@ -77,7 +77,7 @@ modification follow.</p>
|
||||
|
||||
<p>“Copyright” also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.</p>
|
||||
|
||||
|
||||
<p>“The Program” refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as “you”. “Licensees” and
|
||||
“recipients” may be individuals or organizations.</p>
|
||||
@@ -135,7 +135,7 @@ produce the work, or an object code interpreter used to run it.</p>
|
||||
<p>The “Corresponding Source” for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
control those activities. However, it does not include the work\'s
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
@@ -177,7 +177,7 @@ your copyrighted material outside their relationship with you.</p>
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.</p>
|
||||
|
||||
<h4><a name="section3"></a>3. Protecting Users' Legal Rights From Anti-Circumvention Law.</h4>
|
||||
<h4><a name="section3"></a>3. Protecting Users\' Legal Rights From Anti-Circumvention Law.</h4>
|
||||
|
||||
<p>No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
@@ -189,13 +189,13 @@ measures.</p>
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
modification of the work as a means of enforcing, against the work\'s
|
||||
users, your or third parties\' legal rights to forbid circumvention of
|
||||
technological measures.</p>
|
||||
|
||||
<h4><a name="section4"></a>4. Conveying Verbatim Copies.</h4>
|
||||
|
||||
<p>You may convey verbatim copies of the Program's source code as you
|
||||
<p>You may convey verbatim copies of the Program\'s source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
@@ -240,7 +240,7 @@ works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
“aggregate” if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
used to limit the access or legal rights of the compilation\'s users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.</p>
|
||||
@@ -462,7 +462,7 @@ organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
licenses to the work the party\'s predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.</p>
|
||||
@@ -479,9 +479,9 @@ sale, or importing the Program or any portion of it.</p>
|
||||
|
||||
<p>A “contributor” is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's “contributor version”.</p>
|
||||
work thus licensed is called the contributor\'s “contributor version”.</p>
|
||||
|
||||
<p>A contributor's “essential patent claims” are all patent claims
|
||||
<p>A contributor\'s “essential patent claims” are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
@@ -492,7 +492,7 @@ patent sublicenses in a manner consistent with the requirements of
|
||||
this License.</p>
|
||||
|
||||
<p>Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
patent license under the contributor\'s essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.</p>
|
||||
|
||||
@@ -513,10 +513,10 @@ patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. “Knowingly relying” means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
covered work in a country, or your recipient\'s use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.</p>
|
||||
|
||||
|
||||
<p>If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
@@ -544,7 +544,7 @@ or that patent license was granted, prior to 28 March 2007.</p>
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.</p>
|
||||
|
||||
<h4><a name="section12"></a>12. No Surrender of Others' Freedom.</h4>
|
||||
<h4><a name="section12"></a>12. No Surrender of Others\' Freedom.</h4>
|
||||
|
||||
<p>If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
@@ -584,7 +584,7 @@ GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.</p>
|
||||
|
||||
<p>If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
versions of the GNU General Public License can be used, that proxy\'s
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.</p>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"ar_SA":["abdunnasir"],"bg":["dpa_transifex"],"ca":["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","YvanM","ÉricB."],"fr_FR":["chrcha","Llorc","Poussinou"],"gl":["aluaces","pikamoku"],"hu":["jtg"],"it":["Damtux","ed0","FranzMari","noccio","nwandy","rickyroo","technezio"],"ja":["Naofumi"],"nb_NO":["elonus"],"nl":["davtemp","dehart","erikhubers","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"]}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 23 KiB |
@@ -11,40 +11,42 @@ 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.support.v7.app.AppCompatDelegate
|
||||
import android.os.StrictMode
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
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 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)
|
||||
val drawableLogo = AppCompatResources.getDrawable(context, R.mipmap.ic_launcher)
|
||||
return if (drawableLogo is BitmapDrawable)
|
||||
drawableLogo.bitmap
|
||||
else
|
||||
null
|
||||
}
|
||||
|
||||
fun homepageUrl(context: Context) =
|
||||
Uri.parse(context.getString(R.string.homepage_url)).buildUpon()
|
||||
.appendQueryParameter("pk_campaign", BuildConfig.APPLICATION_ID)
|
||||
.appendQueryParameter("pk_kwd", context::class.java.simpleName)
|
||||
.appendQueryParameter("app-version", BuildConfig.VERSION_NAME)
|
||||
.build()!!
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -52,23 +54,44 @@ class App: Application() {
|
||||
super.onCreate()
|
||||
Logger.initialize(this)
|
||||
|
||||
if (BuildConfig.DEBUG)
|
||||
// debug builds
|
||||
StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder()
|
||||
.detectActivityLeaks()
|
||||
.detectFileUriExposure()
|
||||
.detectLeakedClosableObjects()
|
||||
.detectLeakedRegistrationObjects()
|
||||
.detectLeakedSqlLiteObjects()
|
||||
.penaltyLog()
|
||||
.build())
|
||||
else // if (BuildConfig.FLAVOR == FLAVOR_STANDARD)
|
||||
// handle uncaught exceptions in non-debug standard flavor
|
||||
Thread.setDefaultUncaughtExceptionHandler(this)
|
||||
|
||||
if (Build.VERSION.SDK_INT <= 21)
|
||||
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -13,4 +13,18 @@ object Constants {
|
||||
|
||||
const val DEFAULT_SYNC_INTERVAL = 4 * 3600L // 4 hours
|
||||
|
||||
/**
|
||||
* Context label for [org.apache.commons.lang3.exception.ContextedException].
|
||||
* Context value is the [at.bitfire.davdroid.resource.LocalResource]
|
||||
* which is related to the exception cause.
|
||||
*/
|
||||
const val EXCEPTION_CONTEXT_LOCAL_RESOURCE = "localResource"
|
||||
|
||||
/**
|
||||
* Context label for [org.apache.commons.lang3.exception.ContextedException].
|
||||
* Context value is the [okhttp3.HttpUrl] of the remote resource
|
||||
* which is related to the exception cause.
|
||||
*/
|
||||
const val EXCEPTION_CONTEXT_REMOTE_RESOURCE = "remoteResource"
|
||||
|
||||
}
|
||||
@@ -1,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.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 org.apache.commons.collections4.iterators.IteratorChain
|
||||
import org.apache.commons.collections4.iterators.SingletonIterator
|
||||
import java.io.IOException
|
||||
import okhttp3.OkHttpClient
|
||||
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,243 +140,240 @@ 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.
|
||||
* @param dav DavResource to check
|
||||
*/
|
||||
@Throws(IOException::class, HttpException::class, DavException::class)
|
||||
fun queryHomeSets(dav: DavResource) {
|
||||
when (serviceType) {
|
||||
Services.SERVICE_CARDDAV -> {
|
||||
dav.propfind(0, AddressbookHomeSet.NAME, GroupMembership.NAME)
|
||||
for ((resource, addressbookHomeSet) in dav.findProperties(AddressbookHomeSet::class.java))
|
||||
for (href in addressbookHomeSet.hrefs)
|
||||
resource.location.resolve(href)?.let { homeSets += UrlUtils.withTrailingSlash(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
|
||||
}
|
||||
}
|
||||
Services.SERVICE_CALDAV -> {
|
||||
dav.propfind(0, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME)
|
||||
for ((resource, calendarHomeSet) in dav.findProperties(CalendarHomeSet::class.java))
|
||||
for (href in calendarHomeSet.hrefs)
|
||||
resource.location.resolve(href)?.let { homeSets.add(UrlUtils.withTrailingSlash(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 { groupMembership ->
|
||||
related += groupMembership
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
val principal = DavResource(httpClient, principalUrl)
|
||||
queryHomeSets(principal)
|
||||
|
||||
// refresh home sets: calendar-proxy-read/write-for
|
||||
for ((resource, proxyRead) in principal.findProperties(CalendarProxyReadFor::class.java))
|
||||
for (href in proxyRead.hrefs) {
|
||||
Logger.log.fine("Principal is a read-only proxy for $href, checking for home sets")
|
||||
resource.location.resolve(href)?.let { queryHomeSets(DavResource(httpClient, it)) }
|
||||
}
|
||||
for ((resource, proxyWrite) in principal.findProperties(CalendarProxyWriteFor::class.java))
|
||||
for (href in proxyWrite.hrefs) {
|
||||
Logger.log.fine("Principal is a read/write proxy for $href, checking for home sets")
|
||||
resource.location.resolve(href)?.let { queryHomeSets(DavResource(httpClient, it)) }
|
||||
}
|
||||
|
||||
// refresh home sets: direct group memberships
|
||||
principal.properties[GroupMembership::class.java]?.let { groupMembership ->
|
||||
for (href in groupMembership.hrefs) {
|
||||
Logger.log.fine("Principal is member of group $href, checking for home sets")
|
||||
principal.location.resolve(href)?.let { url ->
|
||||
try {
|
||||
queryHomeSets(DavResource(httpClient, url))
|
||||
} catch(e: HttpException) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't query member group", e)
|
||||
} catch(e: DavException) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't query member group", e)
|
||||
}
|
||||
val dav = DavResource(client, url)
|
||||
when (service.type) {
|
||||
Service.TYPE_CARDDAV ->
|
||||
try {
|
||||
dav.propfind(0, DisplayName.NAME, AddressbookHomeSet.NAME, GroupMembership.NAME) { response, _ ->
|
||||
response[AddressbookHomeSet::class.java]?.let { homeSet ->
|
||||
for (href in homeSet.hrefs)
|
||||
dav.location.resolve(href)?.let {
|
||||
val foundUrl = UrlUtils.withTrailingSlash(it)
|
||||
homeSets[foundUrl] = HomeSet(0, service.id, foundUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (recurse)
|
||||
findRelated(dav.location, response)
|
||||
}
|
||||
|
||||
// remember selected collections
|
||||
val selectedCollections = HashSet<HttpUrl>()
|
||||
collections.values
|
||||
.filter { it.selected }
|
||||
.forEach { (url,_) -> HttpUrl.parse(url)?.let { selectedCollections.add(it) } }
|
||||
|
||||
// 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")
|
||||
|
||||
val homeSet = DavResource(httpClient, homeSetUrl)
|
||||
try {
|
||||
homeSet.propfind(1, *CollectionInfo.DAV_PROPERTIES)
|
||||
val itCollections = IteratorChain<DavResource>(homeSet.members.iterator(), homeSet.related.iterator(), SingletonIterator(homeSet))
|
||||
while (itCollections.hasNext()) {
|
||||
val member = itCollections.next()
|
||||
val info = CollectionInfo(member)
|
||||
info.confirmed = true
|
||||
Logger.log.log(Level.FINE, "Found collection", info)
|
||||
|
||||
if ((serviceType == Services.SERVICE_CARDDAV && info.type == CollectionInfo.Type.ADDRESS_BOOK) ||
|
||||
(serviceType == Services.SERVICE_CALDAV && arrayOf(CollectionInfo.Type.CALENDAR, CollectionInfo.Type.WEBCAL).contains(info.type)))
|
||||
collections[member.location] = info
|
||||
}
|
||||
} catch(e: HttpException) {
|
||||
if (e.status 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 {
|
||||
val collection = DavResource(httpClient, url)
|
||||
collection.propfind(0, *CollectionInfo.DAV_PROPERTIES)
|
||||
val info = CollectionInfo(collection)
|
||||
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()
|
||||
} catch(e: HttpException) {
|
||||
if (e.status in arrayOf(403, 404, 410))
|
||||
// delete collection only if it was not accessible (40x)
|
||||
itCollections.remove()
|
||||
else
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// 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,16 @@
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.accounts.Account
|
||||
import android.annotation.TargetApi
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import okhttp3.HttpUrl
|
||||
import org.xbill.DNS.*
|
||||
import java.util.*
|
||||
@@ -22,18 +27,18 @@ 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: String): String {
|
||||
val httpUrl = HttpUrl.parse(url) ?: throw IllegalArgumentException("url not parsable")
|
||||
|
||||
fun lastSegmentOfUrl(url: HttpUrl): String {
|
||||
// the list returned by HttpUrl.pathSegments() is unmodifiable, so we have to create a copy
|
||||
val segments = LinkedList<String>(httpUrl.pathSegments())
|
||||
Collections.reverse(segments)
|
||||
val segments = LinkedList<String>(url.pathSegments)
|
||||
segments.reverse()
|
||||
|
||||
return segments.firstOrNull { it.isNotEmpty() } ?: "/"
|
||||
}
|
||||
@@ -47,14 +52,18 @@ object DavUtils {
|
||||
get the active DNS servers). */
|
||||
val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val activeLink = connectivity.getLinkProperties(connectivity.activeNetwork)
|
||||
val simpleResolvers = activeLink.dnsServers.map {
|
||||
Logger.log.fine("Using DNS server ${it.hostAddress}")
|
||||
val resolver = SimpleResolver()
|
||||
resolver.setAddress(it)
|
||||
resolver
|
||||
}
|
||||
val resolver = ExtendedResolver(simpleResolvers.toTypedArray())
|
||||
lookup.setResolver(resolver)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,4 +90,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,15 +12,14 @@ 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.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 okhttp3.*
|
||||
import okhttp3.brotli.BrotliInterceptor
|
||||
import okhttp3.internal.tls.OkHostnameVerifier
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import java.io.File
|
||||
@@ -29,14 +28,11 @@ import java.net.Proxy
|
||||
import java.net.Socket
|
||||
import java.security.KeyStore
|
||||
import java.security.Principal
|
||||
import java.text.DateFormat
|
||||
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,
|
||||
@@ -44,34 +40,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(30, TimeUnit.SECONDS)
|
||||
.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()
|
||||
|
||||
@@ -81,39 +91,44 @@ 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 {
|
||||
context?.let {
|
||||
val settings = Settings.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.OVERRIDE_PROXY_HOST_DEFAULT,
|
||||
settings.getInt(Settings.OVERRIDE_PROXY_PORT)
|
||||
?: Settings.OVERRIDE_PROXY_PORT_DEFAULT
|
||||
)
|
||||
|
||||
val proxy = Proxy(Proxy.Type.HTTP, address)
|
||||
orig.proxy(proxy)
|
||||
Logger.log.log(Level.INFO, "Using proxy", proxy)
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Can't set proxy, ignoring", e)
|
||||
}
|
||||
|
||||
context?.let {
|
||||
if (BuildConfig.customCerts)
|
||||
customCertManager(CustomCertManager(context, true, !settings.getBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, false)))
|
||||
customCertManager(CustomCertManager(context, true /*BuildConfig.customCertsUI*/,
|
||||
!(settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES)
|
||||
?: Settings.DISTRUST_SYSTEM_CERTIFICATES_DEFAULT)))
|
||||
}
|
||||
|
||||
// use account settings for authentication
|
||||
accountSettings?.let {
|
||||
addAuthentication(null, it.credentials())
|
||||
}
|
||||
}
|
||||
// use account settings for authentication
|
||||
accountSettings?.let {
|
||||
addAuthentication(null, it.credentials())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,12 +138,12 @@ class HttpClient private constructor(
|
||||
|
||||
fun withDiskCache(): Builder {
|
||||
val context = context ?: throw IllegalArgumentException("Context is required to find the cache directory")
|
||||
for (dir in arrayOf(context.externalCacheDir, context.cacheDir)) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -169,18 +184,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)
|
||||
|
||||
@@ -201,12 +218,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)
|
||||
@@ -216,9 +242,11 @@ class HttpClient private constructor(
|
||||
|
||||
|
||||
private object UserAgentInterceptor: Interceptor {
|
||||
|
||||
private val userAgentDate = DateFormat.getDateInstance(DateFormat.SHORT, Locale.US).format(Date(BuildConfig.buildTime))
|
||||
private val userAgent = "DAVdroid/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4android; okhttp3) Android/${Build.VERSION.RELEASE}"
|
||||
// use Locale.US because numbers may be encoded as non-ASCII characters in other locales
|
||||
private val userAgentDateFormat = SimpleDateFormat("yyyy/MM/dd", Locale.US)
|
||||
private val userAgentDate = userAgentDateFormat.format(Date(BuildConfig.buildTime))
|
||||
private val userAgent = "${BuildConfig.userAgent}/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4jvm; " +
|
||||
"okhttp/${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
|
||||
}
|
||||
|
||||
@@ -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,64 +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.annotation.SuppressLint
|
||||
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 {
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_PACKAGE_ADDED || intent.action == Intent.ACTION_PACKAGE_FULLY_REMOVED)
|
||||
updateTaskSync(context)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
fun updateTaskSync(context: Context) {
|
||||
val tasksInstalled = LocalTaskList.tasksProviderAvailable(context)
|
||||
Logger.log.info("Package (un)installed; OpenTasks provider now available = $tasksInstalled")
|
||||
|
||||
// check all accounts and (de)activate OpenTasks if a CalDAV service is defined
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.readableDatabase
|
||||
|
||||
db.query(Services._TABLE, arrayOf(Services.ACCOUNT_NAME),
|
||||
"${Services.SERVICE}=?", arrayOf(Services.SERVICE_CALDAV), null, null, null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val account = Account(cursor.getString(0), context.getString(R.string.account_type))
|
||||
|
||||
if (tasksInstalled) {
|
||||
if (ContentResolver.getIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority) <= 0) {
|
||||
ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 1)
|
||||
ContentResolver.setSyncAutomatically(account, TaskProvider.ProviderName.OpenTasks.authority, true)
|
||||
ContentResolver.addPeriodicSync(account, TaskProvider.ProviderName.OpenTasks.authority, Bundle(), Constants.DEFAULT_SYNC_INTERVAL)
|
||||
}
|
||||
} 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 close() {
|
||||
context.unregisterReceiver(this)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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(appContext: Application) {
|
||||
context = appContext
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteException
|
||||
import android.database.sqlite.SQLiteQueryBuilder
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
|
||||
@Suppress("ClassName")
|
||||
@Database(entities = [
|
||||
Service::class,
|
||||
HomeSet::class,
|
||||
Collection::class
|
||||
], version = 7)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class AppDatabase: RoomDatabase() {
|
||||
|
||||
abstract fun serviceDao(): ServiceDao
|
||||
abstract fun homeSetDao(): HomeSetDao
|
||||
abstract fun collectionDao(): CollectionDao
|
||||
|
||||
companion object {
|
||||
|
||||
private var INSTANCE: AppDatabase? = null
|
||||
|
||||
@Synchronized
|
||||
fun getInstance(context: Context): AppDatabase {
|
||||
INSTANCE?.let { return it }
|
||||
|
||||
val db = Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "services.db")
|
||||
.addMigrations(
|
||||
Migration1_2,
|
||||
Migration2_3,
|
||||
Migration3_4,
|
||||
Migration4_5,
|
||||
Migration5_6,
|
||||
Migration6_7
|
||||
)
|
||||
.fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing
|
||||
.build()
|
||||
INSTANCE = db
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun dump(sb: StringBuilder) {
|
||||
val db = openHelper.readableDatabase
|
||||
db.beginTransactionNonExclusive()
|
||||
|
||||
// iterate through all tables
|
||||
db.query(SQLiteQueryBuilder.buildQueryString(false, "sqlite_master", arrayOf("name"), "type='table'", null, null, null, null)).use { cursorTables ->
|
||||
while (cursorTables.moveToNext()) {
|
||||
val table = cursorTables.getString(0)
|
||||
sb.append(table).append("\n")
|
||||
db.query("SELECT * FROM $table").use { cursor ->
|
||||
// print columns
|
||||
val cols = cursor.columnCount
|
||||
sb.append("\t| ")
|
||||
for (i in 0 until cols)
|
||||
sb .append(" ")
|
||||
.append(cursor.getColumnName(i))
|
||||
.append(" |")
|
||||
sb.append("\n")
|
||||
|
||||
// print rows
|
||||
while (cursor.moveToNext()) {
|
||||
sb.append("\t| ")
|
||||
for (i in 0 until cols) {
|
||||
sb.append(" ")
|
||||
try {
|
||||
val value = cursor.getString(i)
|
||||
if (value != null)
|
||||
sb.append(value
|
||||
.replace("\r", "<CR>")
|
||||
.replace("\n", "<LF>"))
|
||||
else
|
||||
sb.append("<null>")
|
||||
|
||||
} catch (e: SQLiteException) {
|
||||
sb.append("<unprintable>")
|
||||
}
|
||||
sb.append(" |")
|
||||
}
|
||||
sb.append("\n")
|
||||
}
|
||||
sb.append("----------\n")
|
||||
}
|
||||
}
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// migrations
|
||||
|
||||
object Migration6_7: Migration(6, 7) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN privBind INTEGER NOT NULL DEFAULT 1")
|
||||
db.execSQL("ALTER TABLE homeset ADD COLUMN displayName TEXT DEFAULT NULL")
|
||||
}
|
||||
}
|
||||
|
||||
object Migration5_6: Migration(5, 6) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
val sql = arrayOf(
|
||||
// migrate "services" to "service": rename columns, make id NOT NULL
|
||||
"CREATE TABLE service(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"accountName TEXT NOT NULL," +
|
||||
"type TEXT NOT NULL," +
|
||||
"principal TEXT DEFAULT NULL" +
|
||||
")",
|
||||
"CREATE UNIQUE INDEX index_service_accountName_type ON service(accountName, type)",
|
||||
"INSERT INTO service(id, accountName, type, principal) SELECT _id, accountName, service, principal FROM services",
|
||||
"DROP TABLE services",
|
||||
|
||||
// migrate "homesets" to "homeset": rename columns, make id NOT NULL
|
||||
"CREATE TABLE homeset(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"serviceId INTEGER NOT NULL," +
|
||||
"url TEXT NOT NULL," +
|
||||
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
|
||||
")",
|
||||
"CREATE UNIQUE INDEX index_homeset_serviceId_url ON homeset(serviceId, url)",
|
||||
"INSERT INTO homeset(id, serviceId, url) SELECT _id, serviceID, url FROM homesets",
|
||||
"DROP TABLE homesets",
|
||||
|
||||
// migrate "collections" to "collection": rename columns, make id NOT NULL
|
||||
"CREATE TABLE collection(" +
|
||||
"id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," +
|
||||
"serviceId INTEGER NOT NULL," +
|
||||
"type TEXT NOT NULL," +
|
||||
"url TEXT NOT NULL," +
|
||||
"privWriteContent INTEGER NOT NULL DEFAULT 1," +
|
||||
"privUnbind INTEGER NOT NULL DEFAULT 1," +
|
||||
"forceReadOnly INTEGER NOT NULL DEFAULT 0," +
|
||||
"displayName TEXT DEFAULT NULL," +
|
||||
"description TEXT DEFAULT NULL," +
|
||||
"color INTEGER DEFAULT NULL," +
|
||||
"timezone TEXT DEFAULT NULL," +
|
||||
"supportsVEVENT INTEGER DEFAULT NULL," +
|
||||
"supportsVTODO INTEGER DEFAULT NULL," +
|
||||
"supportsVJOURNAL INTEGER DEFAULT NULL," +
|
||||
"source TEXT DEFAULT NULL," +
|
||||
"sync INTEGER NOT NULL DEFAULT 0," +
|
||||
"FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" +
|
||||
")",
|
||||
"CREATE INDEX index_collection_serviceId_type ON collection(serviceId,type)",
|
||||
"INSERT INTO collection(id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync) " +
|
||||
"SELECT _id, serviceID, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync FROM collections",
|
||||
"DROP TABLE collections"
|
||||
)
|
||||
sql.forEach { db.execSQL(it) }
|
||||
}
|
||||
}
|
||||
|
||||
object Migration4_5: Migration(4, 5) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN privWriteContent INTEGER DEFAULT 0 NOT NULL")
|
||||
db.execSQL("UPDATE collections SET privWriteContent=NOT readOnly")
|
||||
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN privUnbind INTEGER DEFAULT 0 NOT NULL")
|
||||
db.execSQL("UPDATE collections SET privUnbind=NOT readOnly")
|
||||
|
||||
// there's no DROP COLUMN in SQLite, so just keep the "readOnly" column
|
||||
}
|
||||
}
|
||||
|
||||
object Migration3_4: Migration(3, 4) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN forceReadOnly INTEGER DEFAULT 0 NOT NULL")
|
||||
}
|
||||
}
|
||||
|
||||
object Migration2_3: Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
// We don't have access to the context in a Room migration now, so
|
||||
// we will just drop those settings from old DAVx5 versions.
|
||||
Logger.log.warning("Dropping settings distrustSystemCerts and overrideProxy*")
|
||||
|
||||
/*val edit = PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
try {
|
||||
db.query("settings", arrayOf("setting", "value"), null, null, null, null, null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
when (cursor.getString(0)) {
|
||||
"distrustSystemCerts" -> edit.putBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, cursor.getInt(1) != 0)
|
||||
"overrideProxy" -> edit.putBoolean(App.OVERRIDE_PROXY, cursor.getInt(1) != 0)
|
||||
"overrideProxyHost" -> edit.putString(App.OVERRIDE_PROXY_HOST, cursor.getString(1))
|
||||
"overrideProxyPort" -> edit.putInt(App.OVERRIDE_PROXY_PORT, cursor.getInt(1))
|
||||
|
||||
StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED ->
|
||||
edit.putBoolean(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED, cursor.getInt(1) != 0)
|
||||
StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED ->
|
||||
edit.putBoolean(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED, cursor.getInt(1) != 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
db.execSQL("DROP TABLE settings")
|
||||
} finally {
|
||||
edit.apply()
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
object Migration1_2: Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN type TEXT NOT NULL DEFAULT ''")
|
||||
db.execSQL("ALTER TABLE collections ADD COLUMN source TEXT DEFAULT NULL")
|
||||
db.execSQL("UPDATE collections SET type=(" +
|
||||
"SELECT CASE service WHEN ? THEN ? ELSE ? END " +
|
||||
"FROM services WHERE _id=collections.serviceID" +
|
||||
")",
|
||||
arrayOf("caldav", "CALENDAR", "ADDRESS_BOOK"))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -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,162 +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 at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.property.*
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import java.io.Serializable
|
||||
|
||||
data class CollectionInfo(
|
||||
val url: String,
|
||||
|
||||
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
|
||||
): Serializable {
|
||||
|
||||
enum class Type {
|
||||
ADDRESS_BOOK,
|
||||
CALENDAR,
|
||||
WEBCAL // iCalendar subscription
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
val DAV_PROPERTIES = arrayOf(
|
||||
ResourceType.NAME,
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
DisplayName.NAME,
|
||||
AddressbookDescription.NAME, SupportedAddressData.NAME,
|
||||
CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME,
|
||||
Source.NAME
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
||||
constructor(dav: DavResource): this(dav.location.toString()) {
|
||||
dav.properties[ResourceType::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.properties[CurrentUserPrivilegeSet::class.java]?.let { privilegeSet ->
|
||||
readOnly = !privilegeSet.mayWriteContent
|
||||
}
|
||||
|
||||
dav.properties[DisplayName::class.java]?.let {
|
||||
if (!it.displayName.isNullOrEmpty())
|
||||
displayName = it.displayName
|
||||
}
|
||||
|
||||
when (type) {
|
||||
Type.ADDRESS_BOOK -> {
|
||||
dav.properties[AddressbookDescription::class.java]?.let { description = it.description }
|
||||
}
|
||||
Type.CALENDAR, Type.WEBCAL -> {
|
||||
dav.properties[CalendarDescription::class.java]?.let { description = it.description }
|
||||
dav.properties[CalendarColor::class.java]?.let { color = it.color }
|
||||
dav.properties[CalendarTimezone::class.java]?.let { timeZone = it.vTimeZone }
|
||||
|
||||
if (type == Type.CALENDAR) {
|
||||
supportsVEVENT = true
|
||||
supportsVTODO = true
|
||||
dav.properties[SupportedCalendarComponentSet::class.java]?.let {
|
||||
supportsVEVENT = it.supportsEvents
|
||||
supportsVTODO = it.supportsTasks
|
||||
}
|
||||
} else { // Type.WEBCAL
|
||||
dav.properties[Source::class.java]?.let { source = it.hrefs.firstOrNull() }
|
||||
supportsVEVENT = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
constructor(values: ContentValues): this(values.getAsString(Collections.URL)) {
|
||||
id = values.getAsLong(Collections.ID)
|
||||
serviceID = values.getAsLong(Collections.SERVICE_ID)
|
||||
type = try {
|
||||
Type.valueOf(values.getAsString(Collections.TYPE))
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
readOnly = values.getAsInteger(Collections.READ_ONLY) != 0
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
abstract class IdEntity {
|
||||
abstract var id: Long
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,40 +8,56 @@
|
||||
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import java.util.logging.Level
|
||||
import at.bitfire.dav4jvm.property.SyncToken
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
data class SyncState(
|
||||
val type: Type,
|
||||
val value: String
|
||||
val value: String,
|
||||
|
||||
/**
|
||||
* Whether this sync state occurred during an initial sync as described
|
||||
* in RFC 6578, which means the initial sync is not complete yet.
|
||||
*/
|
||||
var initialSync: Boolean? = null
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
private const val KEY_TYPE = "type"
|
||||
private const val KEY_VALUE = "value"
|
||||
private const val KEY_INITIAL_SYNC = "initialSync"
|
||||
|
||||
fun fromString(s: String?): SyncState? {
|
||||
if (s == null)
|
||||
return null
|
||||
|
||||
val pos = s.indexOf(':')
|
||||
if (pos == -1)
|
||||
return null
|
||||
|
||||
return try {
|
||||
val json = JSONObject(s)
|
||||
SyncState(
|
||||
Type.valueOf(s.substring(0, pos)),
|
||||
s.substring(pos + 1)
|
||||
Type.valueOf(json.getString(KEY_TYPE)),
|
||||
json.getString(KEY_VALUE),
|
||||
try { json.getBoolean(KEY_INITIAL_SYNC) } catch(e: JSONException) { null }
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't restore SyncState", e)
|
||||
} catch (e: JSONException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun fromSyncToken(token: SyncToken, initialSync: Boolean? = null) =
|
||||
SyncState(Type.SYNC_TOKEN, requireNotNull(token.token), initialSync)
|
||||
|
||||
}
|
||||
|
||||
enum class Type { CTAG, SYNC_TOKEN }
|
||||
|
||||
override fun toString() =
|
||||
"${type.name}:$value"
|
||||
override fun toString(): String {
|
||||
val json = JSONObject()
|
||||
json.put(KEY_TYPE, type.name)
|
||||
json.put(KEY_VALUE, value)
|
||||
initialSync?.let { json.put(KEY_INITIAL_SYNC, it) }
|
||||
return json.toString()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,13 +47,20 @@ 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 {
|
||||
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)))
|
||||
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")
|
||||
|
||||
// Android sometimes seems to lose the initial user data, so set it a second time
|
||||
userData.keySet().forEach { key ->
|
||||
accountManager.setUserData(account, key, userData.getString(key))
|
||||
}
|
||||
|
||||
val addressBook = LocalAddressBook(context, account, provider)
|
||||
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
|
||||
|
||||
@@ -63,22 +69,28 @@ 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 }
|
||||
.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,7 +115,10 @@ class LocalAddressBook(
|
||||
|
||||
}
|
||||
|
||||
override val title = account.name
|
||||
override val tag: String
|
||||
get() = "contacts-${account.name}"
|
||||
|
||||
override val title = account.name!!
|
||||
|
||||
/**
|
||||
* Whether contact groups ([LocalGroup]) are included in query results
|
||||
@@ -119,20 +134,22 @@ class LocalAddressBook(
|
||||
get() {
|
||||
_mainAccount?.let { return it }
|
||||
|
||||
val accountManager = AccountManager.get(context)
|
||||
val name = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME)
|
||||
val type = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE)
|
||||
if (name != null && type != null)
|
||||
return Account(name, type)
|
||||
else
|
||||
throw IllegalStateException("Address book doesn't exist anymore")
|
||||
AccountManager.get(context).let { accountManager ->
|
||||
val name = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME)
|
||||
val type = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE)
|
||||
if (name != null && type != null)
|
||||
return Account(name, type)
|
||||
else
|
||||
throw IllegalStateException("No main account assigned to address book account")
|
||||
}
|
||||
}
|
||||
set(account) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_NAME, account.name)
|
||||
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE, account.type)
|
||||
set(newMainAccount) {
|
||||
AccountManager.get(context).let { accountManager ->
|
||||
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_NAME, newMainAccount.name)
|
||||
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE, newMainAccount.type)
|
||||
}
|
||||
|
||||
_mainAccount = account
|
||||
_mainAccount = newMainAccount
|
||||
}
|
||||
|
||||
var url: String
|
||||
@@ -140,7 +157,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)
|
||||
|
||||
@@ -161,7 +178,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
|
||||
@@ -169,45 +186,53 @@ 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)
|
||||
val future = accountManager.renameAccount(account, newAccountName, {
|
||||
try {
|
||||
// update raw contacts to new account name
|
||||
provider?.let { provider ->
|
||||
val values = ContentValues(1)
|
||||
values.put(RawContacts.ACCOUNT_NAME, newAccountName)
|
||||
provider.update(syncAdapterURI(RawContacts.CONTENT_URI), values, "${RawContacts.ACCOUNT_NAME}=?", arrayOf(account.name))
|
||||
}
|
||||
} catch (e: RemoteException) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't re-assign contacts to new account name", e)
|
||||
}
|
||||
}, null)
|
||||
val future = accountManager.renameAccount(account, newAccountName, null, null)
|
||||
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
|
||||
@@ -236,8 +261,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).
|
||||
@@ -248,16 +273,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -326,7 +365,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,7 +38,7 @@ 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.
|
||||
@@ -52,23 +52,22 @@ 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)
|
||||
values.put(Calendars.NAME, info.url.toString())
|
||||
values.put(Calendars.CALENDAR_DISPLAY_NAME, if (info.displayName.isNullOrBlank()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
|
||||
|
||||
if (withColor)
|
||||
values.put(Calendars.CALENDAR_COLOR, info.color ?: Constants.DAVDROID_GREEN_RGBA)
|
||||
|
||||
if (info.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 +77,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 +105,38 @@ 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
|
||||
for (localEvent in queryEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null)) {
|
||||
try {
|
||||
val event = requireNotNull(localEvent.event)
|
||||
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 (localEvent.weAreOrganizer) // increase sequence only if we're the organizer (i.e. not for attendee changes)
|
||||
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 +145,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 +183,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 +223,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
|
||||
@@ -38,8 +38,6 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
private val groupMemberships = HashSet<Long>()
|
||||
|
||||
override var flags: Int = 0
|
||||
private set
|
||||
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<LocalContact,*>, values: ContentValues)
|
||||
: super(addressBook, values) {
|
||||
@@ -95,7 +93,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
|
||||
@@ -216,7 +214,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
|
||||
@@ -239,6 +237,12 @@ class LocalContact: AndroidContact, LocalAddress {
|
||||
}
|
||||
|
||||
|
||||
// data rows
|
||||
override fun buildContact(builder: ContentProviderOperation.Builder, update: Boolean) {
|
||||
builder.withValue(COLUMN_FLAGS, flags)
|
||||
super.buildContact(builder, update)
|
||||
}
|
||||
|
||||
// factory
|
||||
|
||||
object Factory: AndroidContactFactory<LocalContact> {
|
||||
|
||||
@@ -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,12 +20,12 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
|
||||
|
||||
companion object {
|
||||
init {
|
||||
ICalendar.prodId = ProdId("+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ical4j/2.x")
|
||||
ICalendar.prodId = ProdId("+//IDN bitfire.at//${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
|
||||
}
|
||||
|
||||
override var fileName: String? = null
|
||||
@@ -53,7 +52,6 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
|
||||
}
|
||||
|
||||
override fun populateEvent(row: ContentValues) {
|
||||
super.populateEvent(row)
|
||||
val event = requireNotNull(event)
|
||||
|
||||
event.uid = row.getAsString(Events.UID_2445)
|
||||
@@ -61,6 +59,8 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
|
||||
|
||||
val isOrganizer = row.getAsInteger(Events.IS_ORGANIZER)
|
||||
weAreOrganizer = isOrganizer != null && isOrganizer != 0
|
||||
|
||||
super.populateEvent(row)
|
||||
}
|
||||
|
||||
override fun buildEvent(recurrence: Event?, builder: ContentProviderOperation.Builder) {
|
||||
@@ -72,9 +72,9 @@ 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)
|
||||
@@ -106,7 +106,7 @@ class LocalEvent: AndroidEvent, LocalResource<Event> {
|
||||
|
||||
override fun clearDirty(eTag: String?) {
|
||||
val values = ContentValues(2)
|
||||
values.put(CalendarContract.Events.DIRTY, 0)
|
||||
values.put(Events.DIRTY, 0)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(COLUMN_SEQUENCE, event!!.sequence)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
@@ -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.*
|
||||
|
||||
@@ -102,11 +101,10 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
||||
|
||||
|
||||
override var flags: Int = 0
|
||||
private set
|
||||
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, values: ContentValues):
|
||||
super(addressBook, values) {
|
||||
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, values: ContentValues)
|
||||
: super(addressBook, values) {
|
||||
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
|
||||
}
|
||||
|
||||
@@ -118,6 +116,7 @@ class LocalGroup: AndroidGroup, LocalAddress {
|
||||
|
||||
override fun contentValues(): ContentValues {
|
||||
val values = super.contentValues()
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
|
||||
val members = Parcel.obtain()
|
||||
try {
|
||||
@@ -200,7 +199,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,34 @@ interface LocalResource<in TData: Any> {
|
||||
*/
|
||||
val id: Long?
|
||||
|
||||
/**
|
||||
* Remote file name for the resource, for instance `mycontact.vcf`.
|
||||
*/
|
||||
val fileName: String?
|
||||
var eTag: String?
|
||||
val flags: Int
|
||||
|
||||
/**
|
||||
* Generates a new UID and file name and assigns them to this resource. Typically used
|
||||
* before uploading a resource which has just been created locally.
|
||||
*/
|
||||
fun assignNameAndUID()
|
||||
|
||||
/**
|
||||
* Unsets the /dirty/ field of the resource. Typically used after successfully uploading a
|
||||
* locally modified resource.
|
||||
*
|
||||
* @param eTag ETag of the uploaded resource as returned by the server (null if the server didn't return one)
|
||||
*/
|
||||
fun clearDirty(eTag: String?)
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -38,14 +40,18 @@ class LocalTaskList private constructor(
|
||||
fun tasksProviderAvailable(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||
return context.packageManager.resolveContentProvider(TaskProvider.ProviderName.OpenTasks.authority, 0) != null
|
||||
else {
|
||||
val provider = TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)
|
||||
provider?.use { return true }
|
||||
else
|
||||
try {
|
||||
TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.use {
|
||||
return true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// couldn't acquire task provider
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fun create(account: Account, provider: TaskProvider, info: CollectionInfo): Uri {
|
||||
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)
|
||||
@@ -53,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
|
||||
@@ -64,16 +71,13 @@ 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)
|
||||
values.put(TaskLists._SYNC_ID, info.url.toString())
|
||||
values.put(TaskLists.LIST_NAME, if (info.displayName.isNullOrBlank()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
|
||||
|
||||
if (withColor)
|
||||
@@ -84,6 +88,9 @@ class LocalTaskList private constructor(
|
||||
|
||||
}
|
||||
|
||||
override val tag: String
|
||||
get() = "tasks-${account.name}-$id"
|
||||
|
||||
override val title: String
|
||||
get() = name ?: id.toString()
|
||||
|
||||
@@ -109,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()
|
||||
|
||||
@@ -142,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> {
|
||||
|
||||
|
||||
+130
-218
@@ -5,38 +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.content.*
|
||||
import android.os.Build
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.os.Parcel
|
||||
import android.os.RemoteException
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.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
|
||||
|
||||
/**
|
||||
@@ -44,37 +44,46 @@ 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"
|
||||
const val KEY_CERTIFICATE_ALIAS = "certificate_alias"
|
||||
|
||||
const val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false)
|
||||
const val WIFI_ONLY_DEFAULT = false
|
||||
const val KEY_WIFI_ONLY_SSIDS = "wifi_only_ssids" // restrict sync to specific WiFi SSIDs
|
||||
|
||||
/** Time range limitation to the past [in days]
|
||||
value = null default value (DEFAULT_TIME_RANGE_PAST_DAYS)
|
||||
< 0 (-1) no limit
|
||||
>= 0 entries more than n days in the past won't be synchronized
|
||||
/** 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"
|
||||
@@ -102,9 +111,10 @@ class AccountSettings(
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
val accountManager: AccountManager = AccountManager.get(context)
|
||||
val settings = Settings.getInstance(context)
|
||||
|
||||
init {
|
||||
synchronized(AccountSettings::class.java) {
|
||||
@@ -159,14 +169,14 @@ class AccountSettings(
|
||||
}
|
||||
|
||||
fun getSyncWifiOnly() = if (settings.has(KEY_WIFI_ONLY))
|
||||
settings.getBoolean(KEY_WIFI_ONLY, false)
|
||||
settings.getBoolean(KEY_WIFI_ONLY) ?: WIFI_ONLY_DEFAULT
|
||||
else
|
||||
accountManager.getUserData(account, KEY_WIFI_ONLY) != null
|
||||
fun setSyncWiFiOnly(wiFiOnly: Boolean) =
|
||||
accountManager.setUserData(account, KEY_WIFI_ONLY, if (wiFiOnly) "1" else null)
|
||||
|
||||
fun getSyncWifiOnlySSIDs(): List<String>? = (if (settings.has(KEY_WIFI_ONLY_SSIDS))
|
||||
settings.getString(KEY_WIFI_ONLY_SSIDS, null)
|
||||
settings.getString(KEY_WIFI_ONLY_SSIDS)
|
||||
else
|
||||
accountManager.getUserData(account, KEY_WIFI_ONLY_SSIDS))?.split(',')
|
||||
fun setSyncWifiOnlySSIDs(ssids: List<String>?) =
|
||||
@@ -177,25 +187,57 @@ class AccountSettings(
|
||||
|
||||
fun getTimeRangePastDays(): Int? {
|
||||
val strDays = accountManager.getUserData(account, KEY_TIME_RANGE_PAST_DAYS)
|
||||
if (strDays != null) {
|
||||
val days = Integer.valueOf(strDays)
|
||||
return if (days < 0) null else days
|
||||
return if (strDays != null) {
|
||||
val days = strDays.toInt()
|
||||
if (days < 0)
|
||||
null
|
||||
else
|
||||
days
|
||||
} else
|
||||
return DEFAULT_TIME_RANGE_PAST_DAYS
|
||||
DEFAULT_TIME_RANGE_PAST_DAYS
|
||||
}
|
||||
|
||||
fun setTimeRangePastDays(days: Int?) =
|
||||
accountManager.setUserData(account, KEY_TIME_RANGE_PAST_DAYS, (days ?: -1).toString())
|
||||
|
||||
/**
|
||||
* Takes the default alarm setting (in this order) from
|
||||
*
|
||||
* 1. the local account settings
|
||||
* 2. the settings provider (unless the value is -1 there).
|
||||
*
|
||||
* @return A default reminder shall be created this number of minutes before the start of every
|
||||
* non-full-day event without reminder. *null*: No default reminders shall be created.
|
||||
*/
|
||||
fun getDefaultAlarm() =
|
||||
accountManager.getUserData(account, KEY_DEFAULT_ALARM)?.toInt() ?:
|
||||
settings.getInt(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.getInt(KEY_DEFAULT_ALARM).takeIf { it != -1 })
|
||||
null
|
||||
else
|
||||
minBefore?.toString())
|
||||
|
||||
fun getManageCalendarColors() = if (settings.has(KEY_MANAGE_CALENDAR_COLORS))
|
||||
settings.getBoolean(KEY_MANAGE_CALENDAR_COLORS, false)
|
||||
settings.getBoolean(KEY_MANAGE_CALENDAR_COLORS) ?: false
|
||||
else
|
||||
accountManager.getUserData(account, KEY_MANAGE_CALENDAR_COLORS) == null
|
||||
fun setManageCalendarColors(manage: Boolean) =
|
||||
accountManager.setUserData(account, KEY_MANAGE_CALENDAR_COLORS, if (manage) null else "0")
|
||||
|
||||
fun getEventColors() = if (settings.has(KEY_EVENT_COLORS))
|
||||
settings.getBoolean(KEY_EVENT_COLORS, false)
|
||||
settings.getBoolean(KEY_EVENT_COLORS) ?: false
|
||||
else
|
||||
accountManager.getUserData(account, KEY_EVENT_COLORS) != null
|
||||
fun setEventColors(useColors: Boolean) =
|
||||
@@ -204,7 +246,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 {
|
||||
@@ -223,7 +265,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 {
|
||||
@@ -238,19 +280,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)
|
||||
@@ -270,16 +352,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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +370,7 @@ class AccountSettings(
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@SuppressLint("Recycle", "ParcelClassLoader")
|
||||
private fun update_5_6() {
|
||||
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { provider ->
|
||||
val parcel = Parcel.obtain()
|
||||
@@ -306,18 +387,16 @@ class AccountSettings(
|
||||
else {
|
||||
parcel.unmarshall(raw, 0, raw.size)
|
||||
parcel.setDataPosition(0)
|
||||
val params = parcel.readBundle()
|
||||
val url = params.getString("url")
|
||||
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)))
|
||||
if (!accountManager.addAccountExplicitly(addressBookAccount, null, LocalAddressBook.initialUserData(account, info.url.toString())))
|
||||
throw ContactsStorageException("Couldn't create address book account")
|
||||
|
||||
// move contacts to new address book
|
||||
@@ -341,10 +420,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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,7 +436,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")
|
||||
@@ -368,170 +444,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")
|
||||
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,32 +8,31 @@
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import at.bitfire.davdroid.App
|
||||
import android.content.Context
|
||||
|
||||
open class DefaultsProvider(
|
||||
private val allowOverride: Boolean = true
|
||||
): Provider {
|
||||
): SettingsProvider {
|
||||
|
||||
open val booleanDefaults = mapOf(
|
||||
Pair(App.DISTRUST_SYSTEM_CERTIFICATES, false),
|
||||
Pair(App.OVERRIDE_PROXY, false)
|
||||
Pair(Settings.DISTRUST_SYSTEM_CERTIFICATES, Settings.DISTRUST_SYSTEM_CERTIFICATES_DEFAULT),
|
||||
Pair(Settings.OVERRIDE_PROXY, Settings.OVERRIDE_PROXY_DEFAULT)
|
||||
)
|
||||
|
||||
open val intDefaults = mapOf(
|
||||
Pair(App.OVERRIDE_PROXY_PORT, App.OVERRIDE_PROXY_PORT_DEFAULT)
|
||||
Pair(Settings.OVERRIDE_PROXY_PORT, Settings.OVERRIDE_PROXY_PORT_DEFAULT)
|
||||
)
|
||||
|
||||
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, Settings.OVERRIDE_PROXY_HOST_DEFAULT)
|
||||
)
|
||||
|
||||
|
||||
override fun close() {
|
||||
override fun forceReload() {
|
||||
}
|
||||
|
||||
override fun forceReload() {
|
||||
override fun close() {
|
||||
}
|
||||
|
||||
|
||||
@@ -71,4 +70,9 @@ open class DefaultsProvider(
|
||||
|
||||
override fun remove(key: String) = false
|
||||
|
||||
|
||||
class Factory : ISettingsProviderFactory {
|
||||
override fun getProviders(context: Context) = listOf(DefaultsProvider())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.content.Context
|
||||
|
||||
interface ISettingsProviderFactory {
|
||||
|
||||
fun getProviders(context: Context): List<SettingsProvider>
|
||||
|
||||
}
|
||||
@@ -8,62 +8,90 @@
|
||||
|
||||
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 androidx.annotation.WorkerThread
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
class Settings: Service(), Provider.Observer {
|
||||
class Settings(
|
||||
appContext: Context
|
||||
) {
|
||||
|
||||
private val providers = LinkedList<Provider>()
|
||||
private val observers = LinkedList<WeakReference<ISettingsObserver>>()
|
||||
companion object {
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
override fun onCreate() {
|
||||
Logger.log.info("Initializing Settings service")
|
||||
// settings keys and default values
|
||||
const val DISTRUST_SYSTEM_CERTIFICATES = "distrust_system_certs"
|
||||
const val DISTRUST_SYSTEM_CERTIFICATES_DEFAULT = false
|
||||
const val OVERRIDE_PROXY = "override_proxy"
|
||||
const val OVERRIDE_PROXY_DEFAULT = false
|
||||
const val OVERRIDE_PROXY_HOST = "override_proxy_host"
|
||||
const val OVERRIDE_PROXY_PORT = "override_proxy_port"
|
||||
|
||||
// always add a defaults provider first
|
||||
providers.add(DefaultsProvider())
|
||||
const val OVERRIDE_PROXY_HOST_DEFAULT = "localhost"
|
||||
const val OVERRIDE_PROXY_PORT_DEFAULT = 8118
|
||||
|
||||
|
||||
private var singleton: Settings? = null
|
||||
|
||||
fun getInstance(context: Context): Settings {
|
||||
singleton?.let { return it }
|
||||
|
||||
val newInstance = Settings(context.applicationContext)
|
||||
singleton = newInstance
|
||||
return newInstance
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
private val providers = LinkedList<SettingsProvider>()
|
||||
private val observers = LinkedList<WeakReference<OnChangeListener>>()
|
||||
|
||||
override fun onTrimMemory(level: Int) {
|
||||
stopSelf()
|
||||
init {
|
||||
synchronized(javaClass) { // ServiceLoader is not thread-safe
|
||||
val factories = ServiceLoader.load(ISettingsProviderFactory::class.java)
|
||||
Logger.log.fine("Loading settings providers from ${factories.count()} factories")
|
||||
factories.forEach { factory ->
|
||||
providers.addAll(factory.getProviders(appContext))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun forceReload() {
|
||||
providers.forEach { it.forceReload() }
|
||||
providers.forEach {
|
||||
it.forceReload()
|
||||
}
|
||||
onSettingsChanged()
|
||||
}
|
||||
|
||||
override fun onReload() {
|
||||
observers.forEach {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
it.get()?.onSettingsChanged()
|
||||
|
||||
/*** OBSERVERS ***/
|
||||
|
||||
fun addOnChangeListener(observer: OnChangeListener) {
|
||||
synchronized(this) {
|
||||
observers += WeakReference(observer)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeOnChangeListener(observer: OnChangeListener) {
|
||||
synchronized(this) {
|
||||
observers.removeAll { it.get() == null || it.get() == observer }
|
||||
}
|
||||
}
|
||||
|
||||
fun onSettingsChanged() {
|
||||
synchronized(this) {
|
||||
observers.mapNotNull { it.get() }.forEach {
|
||||
it.onSettingsChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun has(key: String): Boolean {
|
||||
/*** SETTINGS ACCESS ***/
|
||||
|
||||
fun has(key: String): Boolean {
|
||||
Logger.log.fine("Looking for setting $key")
|
||||
var result = false
|
||||
for (provider in providers)
|
||||
@@ -83,7 +111,7 @@ class Settings: Service(), Provider.Observer {
|
||||
return result
|
||||
}
|
||||
|
||||
private fun<T> getValue(key: String, reader: (Provider) -> Pair<T?, Boolean>): T? {
|
||||
private fun<T> getValue(key: String, reader: (SettingsProvider) -> Pair<T?, Boolean>): T? {
|
||||
Logger.log.fine("Looking up setting $key")
|
||||
var result: T? = null
|
||||
for (provider in providers)
|
||||
@@ -101,16 +129,16 @@ class Settings: Service(), Provider.Observer {
|
||||
}
|
||||
|
||||
fun getBoolean(key: String) =
|
||||
getValue(key, { provider -> provider.getBoolean(key) })
|
||||
getValue(key) { provider -> provider.getBoolean(key) }
|
||||
|
||||
fun getInt(key: String) =
|
||||
getValue(key, { provider -> provider.getInt(key) })
|
||||
getValue(key) { provider -> provider.getInt(key) }
|
||||
|
||||
fun getLong(key: String) =
|
||||
getValue(key, { provider -> provider.getLong(key) })
|
||||
getValue(key) { provider -> provider.getLong(key) }
|
||||
|
||||
fun getString(key: String) =
|
||||
getValue(key, { provider -> provider.getString(key) })
|
||||
getValue(key) { provider -> provider.getString(key) }
|
||||
|
||||
|
||||
fun isWritable(key: String): Boolean {
|
||||
@@ -124,7 +152,7 @@ class Settings: Service(), Provider.Observer {
|
||||
return false
|
||||
}
|
||||
|
||||
private fun<T> putValue(key: String, value: T?, writer: (Provider) -> Boolean): Boolean {
|
||||
private fun<T> putValue(key: String, value: T?, writer: (SettingsProvider) -> Boolean): Boolean {
|
||||
Logger.log.fine("Trying to write setting $key = $value")
|
||||
for (provider in providers) {
|
||||
val (writable, further) = provider.isWritable(key)
|
||||
@@ -143,16 +171,16 @@ class Settings: Service(), Provider.Observer {
|
||||
}
|
||||
|
||||
fun putBoolean(key: String, value: Boolean?) =
|
||||
putValue(key, value, { provider -> provider.putBoolean(key, value) })
|
||||
putValue(key, value) { provider -> provider.putBoolean(key, value) }
|
||||
|
||||
fun putInt(key: String, value: Int?) =
|
||||
putValue(key, value, { provider -> provider.putInt(key, value) })
|
||||
putValue(key, value) { provider -> provider.putInt(key, value) }
|
||||
|
||||
fun putLong(key: String, value: Long?) =
|
||||
putValue(key, value, { provider -> provider.putLong(key, value) })
|
||||
putValue(key, value) { provider -> provider.putLong(key, value) }
|
||||
|
||||
fun putString(key: String, value: String?) =
|
||||
putValue(key, value, { provider -> provider.putString(key, value) })
|
||||
putValue(key, value) { provider -> provider.putString(key, value) }
|
||||
|
||||
fun remove(key: String): Boolean {
|
||||
var deleted = false
|
||||
@@ -161,115 +189,13 @@ class Settings: Service(), Provider.Observer {
|
||||
}
|
||||
|
||||
|
||||
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() {
|
||||
try {
|
||||
context.unbindService(serviceConn)
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't unbind Settings service", e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun getInstance(context: Context): Stub? {
|
||||
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)
|
||||
}
|
||||
|
||||
interface OnChangeListener {
|
||||
/**
|
||||
* Will be called when something has changed in a [SettingsProvider].
|
||||
* Runs in worker thread!
|
||||
*/
|
||||
@WorkerThread
|
||||
fun onSettingsChanged()
|
||||
}
|
||||
|
||||
}
|
||||
+2
-8
@@ -8,11 +8,10 @@
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import java.io.Closeable
|
||||
|
||||
interface Provider: Closeable {
|
||||
interface SettingsProvider {
|
||||
|
||||
fun forceReload()
|
||||
fun close()
|
||||
|
||||
fun has(key: String): Pair<Boolean, Boolean>
|
||||
|
||||
@@ -30,9 +29,4 @@ interface Provider: Closeable {
|
||||
|
||||
fun remove(key: String): Boolean
|
||||
|
||||
|
||||
interface Observer {
|
||||
fun onReload()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -13,11 +13,11 @@ import android.content.Context.MODE_PRIVATE
|
||||
import android.content.SharedPreferences
|
||||
import android.preference.PreferenceManager
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
|
||||
class SharedPreferencesProvider(
|
||||
context: Context
|
||||
): Provider {
|
||||
val context: Context
|
||||
): SettingsProvider, SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
companion object {
|
||||
private const val META_VERSION = "version"
|
||||
@@ -34,14 +34,21 @@ class SharedPreferencesProvider(
|
||||
firstCall(context)
|
||||
meta.edit().putInt(META_VERSION, CURRENT_VERSION).apply()
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
preferences.registerOnSharedPreferenceChangeListener(this)
|
||||
}
|
||||
|
||||
override fun forceReload() {
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
preferences.unregisterOnSharedPreferenceChangeListener(this)
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
Settings.getInstance(context).onSettingsChanged()
|
||||
}
|
||||
|
||||
|
||||
override fun has(key: String) =
|
||||
Pair(preferences.contains(key), true)
|
||||
@@ -56,44 +63,44 @@ class SharedPreferencesProvider(
|
||||
}
|
||||
|
||||
override fun getBoolean(key: String): Pair<Boolean?, Boolean> =
|
||||
getValue(key, { preferences -> preferences.getBoolean(key, /* will never be used: */ false) })
|
||||
getValue(key) { preferences -> preferences.getBoolean(key, /* will never be used: */ false) }
|
||||
|
||||
override fun getInt(key: String): Pair<Int?, Boolean> =
|
||||
getValue(key, { preferences -> preferences.getInt(key, /* will never be used: */ -1) })
|
||||
getValue(key) { preferences -> preferences.getInt(key, /* will never be used: */ -1) }
|
||||
|
||||
override fun getLong(key: String): Pair<Long?, Boolean> =
|
||||
getValue(key, { preferences -> preferences.getLong(key, /* will never be used: */ -1) })
|
||||
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) })
|
||||
getValue(key) { preferences -> preferences.getString(key, /* will never be used: */ null) }
|
||||
|
||||
|
||||
override fun isWritable(key: String) =
|
||||
Pair(true, true)
|
||||
Pair(first = true, second = true)
|
||||
|
||||
private fun<T> putValue(key: String, value: T?, writer: (SharedPreferences.Editor, T) -> Unit): Boolean {
|
||||
if (value == null)
|
||||
return remove(key)
|
||||
return if (value == null)
|
||||
remove(key)
|
||||
else {
|
||||
Logger.log.fine("Writing setting $key = $value")
|
||||
val edit = preferences.edit()
|
||||
writer(edit, value)
|
||||
edit.apply()
|
||||
return true
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
override fun putBoolean(key: String, value: Boolean?) =
|
||||
putValue(key, value, { editor, v -> editor.putBoolean(key, v) })
|
||||
putValue(key, value) { editor, v -> editor.putBoolean(key, v) }
|
||||
|
||||
override fun putInt(key: String, value: Int?) =
|
||||
putValue(key, value, { editor, v -> editor.putInt(key, v) })
|
||||
putValue(key, value) { editor, v -> editor.putInt(key, v) }
|
||||
|
||||
override fun putLong(key: String, value: Long?) =
|
||||
putValue(key, value, { editor, v -> editor.putLong(key, v) })
|
||||
putValue(key, value) { editor, v -> editor.putLong(key, v) }
|
||||
|
||||
override fun putString(key: String, value: String?) =
|
||||
putValue(key, value, { editor, v -> editor.putString(key, v) })
|
||||
putValue(key, value) { editor, v -> editor.putString(key, v) }
|
||||
|
||||
override fun remove(key: String): Boolean {
|
||||
Logger.log.fine("Removing setting $key")
|
||||
@@ -114,7 +121,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 : ISettingsProviderFactory {
|
||||
override fun getProviders(context: Context) = listOf(SharedPreferencesProvider(context))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
+59
-68
@@ -7,41 +7,39 @@
|
||||
*/
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
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.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.provider.ContactsContract
|
||||
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.settings.AccountSettings
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import java.util.logging.Level
|
||||
|
||||
class AddressBooksSyncAdapterService: SyncAdapterService() {
|
||||
class AddressBooksSyncAdapterService : SyncAdapterService() {
|
||||
|
||||
override fun syncAdapter() = AddressBooksSyncAdapter(this)
|
||||
|
||||
|
||||
class AddressBooksSyncAdapter(
|
||||
context: Context
|
||||
): SyncAdapter(context) {
|
||||
|
||||
override fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, addressBooksProvider: ContentProviderClient, syncResult: SyncResult) {
|
||||
val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
|
||||
if (contactsProvider == null) {
|
||||
Logger.log.severe("Couldn't access contacts provider")
|
||||
syncResult.databaseError = true
|
||||
return
|
||||
}
|
||||
) : SyncAdapter(context) {
|
||||
|
||||
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,61 +48,50 @@ class AddressBooksSyncAdapterService: SyncAdapterService() {
|
||||
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings))
|
||||
return
|
||||
|
||||
updateLocalAddressBooks(contactsProvider, account)
|
||||
|
||||
val accountManager = AccountManager.get(context)
|
||||
for (addressBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) {
|
||||
Logger.log.log(Level.INFO, "Running sync for address book", addressBookAccount)
|
||||
val syncExtras = Bundle(extras)
|
||||
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_SETTINGS, true)
|
||||
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, true)
|
||||
ContentResolver.requestSync(addressBookAccount, ContactsContract.AUTHORITY, syncExtras)
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
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)
|
||||
}
|
||||
|
||||
Logger.log.info("Address book sync complete")
|
||||
}
|
||||
|
||||
private fun updateLocalAddressBooks(provider: ContentProviderClient, account: Account) {
|
||||
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<String, CollectionInfo> {
|
||||
val collections = mutableMapOf<String, CollectionInfo>()
|
||||
service?.let {
|
||||
db.query(Collections._TABLE, null,
|
||||
Collections.SERVICE_ID + "=? AND " + Collections.SYNC, arrayOf(service.toString()), null, null, null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val values = ContentValues(cursor.columnCount)
|
||||
DatabaseUtils.cursorRowToContentValues(cursor, values)
|
||||
val info = CollectionInfo(values)
|
||||
collections[info.url] = info
|
||||
}
|
||||
}
|
||||
}
|
||||
return collections
|
||||
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
|
||||
}
|
||||
|
||||
// enumerate remote and local address books
|
||||
val service = getService()
|
||||
val remote = remoteAddressBooks(service)
|
||||
|
||||
// delete/update local address books
|
||||
for (addressBook in LocalAddressBook.findAll(context, provider, account)) {
|
||||
val url = addressBook.url
|
||||
val info = remote[url]
|
||||
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()
|
||||
@@ -113,20 +100,24 @@ class AddressBooksSyncAdapterService: SyncAdapterService() {
|
||||
try {
|
||||
Logger.log.log(Level.FINE, "Updating local address book $url", info)
|
||||
addressBook.update(info)
|
||||
} catch(e: Exception) {
|
||||
} 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
|
||||
remoteAddressBooks -= url
|
||||
}
|
||||
}
|
||||
|
||||
// create new local address books
|
||||
for ((_, info) in remote) {
|
||||
for ((_, info) in remoteAddressBooks) {
|
||||
Logger.log.log(Level.INFO, "Adding local address book", info)
|
||||
LocalAddressBook.create(context, provider, account, info)
|
||||
LocalAddressBook.create(context, contactsProvider, account, info)
|
||||
}
|
||||
} finally {
|
||||
contactsProvider?.closeCompat()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,284 +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.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import at.bitfire.dav4android.DavCollection
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.exception.ConflictException
|
||||
import at.bitfire.dav4android.exception.DavException
|
||||
import at.bitfire.dav4android.exception.HttpException
|
||||
import at.bitfire.dav4android.exception.PreconditionFailedException
|
||||
import at.bitfire.dav4android.property.GetCTag
|
||||
import at.bitfire.dav4android.property.GetETag
|
||||
import at.bitfire.dav4android.property.SyncToken
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.HttpClient
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.davdroid.resource.LocalCollection
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.RequestBody
|
||||
import java.util.logging.Level
|
||||
|
||||
abstract class BaseDavSyncManager<ResourceType: LocalResource<*>, out CollectionType: LocalCollection<ResourceType>, RemoteType: DavCollection>(
|
||||
context: Context,
|
||||
settings: ISettings,
|
||||
account: Account,
|
||||
accountSettings: AccountSettings,
|
||||
extras: Bundle,
|
||||
authority: String,
|
||||
syncResult: SyncResult,
|
||||
localCollection: CollectionType
|
||||
): SyncManager<ResourceType, CollectionType>(context, settings, account, accountSettings, extras, authority, syncResult, localCollection), AutoCloseable {
|
||||
|
||||
protected val httpClient = HttpClient.Builder(context, settings, accountSettings).build()
|
||||
|
||||
protected lateinit var collectionURL: HttpUrl
|
||||
protected lateinit var davCollection: RemoteType
|
||||
|
||||
override fun close() {
|
||||
httpClient.close()
|
||||
}
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
// always re-sync on manual syncs
|
||||
if (extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL))
|
||||
localCollection.lastSyncState = null
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Process locally deleted entries (DELETE them on the server as well).
|
||||
* Checks for thread interruption before each request to allow quick sync cancellation.
|
||||
*/
|
||||
override fun processLocallyDeleted(): Boolean {
|
||||
var numDeleted = 0
|
||||
|
||||
// Remove locally deleted entries from server (if they have a name, i.e. if they were uploaded before),
|
||||
// but only if they don't have changed on the server. Then finally remove them from the local address book.
|
||||
val localList = localCollection.findDeleted()
|
||||
for (local in localList)
|
||||
useLocal(local, {
|
||||
abortIfCancelled()
|
||||
|
||||
val fileName = local.fileName
|
||||
if (fileName != null) {
|
||||
Logger.log.info("$fileName has been deleted locally -> deleting from server")
|
||||
|
||||
useRemote(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build()), { remote ->
|
||||
try {
|
||||
remote.delete(local.eTag)
|
||||
numDeleted++
|
||||
} catch (e: HttpException) {
|
||||
Logger.log.warning("Couldn't delete $fileName from server; ignoring (may be downloaded again)")
|
||||
}
|
||||
})
|
||||
} else
|
||||
Logger.log.info("Removing local record #${local.id} which has been deleted locally and was never uploaded")
|
||||
local.delete()
|
||||
syncResult.stats.numDeletes++
|
||||
})
|
||||
Logger.log.info("Removed $numDeleted record(s) from server")
|
||||
return numDeleted > 0
|
||||
}
|
||||
|
||||
protected abstract fun prepareUpload(resource: ResourceType): RequestBody
|
||||
|
||||
/**
|
||||
* Uploads dirty records to the server, using a PUT request for each record.
|
||||
* Checks for thread interruption before each request to allow quick sync cancellation.
|
||||
*/
|
||||
override 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()
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
try {
|
||||
if (local.eTag == null) {
|
||||
Logger.log.info("Uploading new record $fileName")
|
||||
remote.put(body, null, true)
|
||||
} else {
|
||||
Logger.log.info("Uploading locally modified record $fileName")
|
||||
remote.put(body, local.eTag, false)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
val newETag = remote.properties[GetETag::class.java]
|
||||
val eTag: String?
|
||||
if (newETag != null) {
|
||||
eTag = newETag.eTag
|
||||
Logger.log.fine("Received new ETag=$eTag after uploading")
|
||||
} else {
|
||||
Logger.log.fine("Didn't receive new ETag after uploading, setting to null")
|
||||
eTag = null
|
||||
}
|
||||
|
||||
local.clearDirty(eTag)
|
||||
})
|
||||
})
|
||||
Logger.log.info("Sent $numUploaded record(s) to server")
|
||||
return numUploaded > 0
|
||||
}
|
||||
|
||||
override fun syncRequired(): Boolean {
|
||||
val localState = localCollection.lastSyncState
|
||||
val remoteState = syncState(false)
|
||||
Logger.log.info("Local sync state = $localState, remote sync state = $remoteState")
|
||||
return when {
|
||||
remoteState?.type == SyncState.Type.SYNC_TOKEN -> {
|
||||
val lastKnownToken = localState?.takeIf { it.type == SyncState.Type.SYNC_TOKEN }?.value
|
||||
lastKnownToken != remoteState.value
|
||||
}
|
||||
remoteState?.type == SyncState.Type.CTAG -> {
|
||||
val lastKnownCTag = localState?.takeIf { it.type == SyncState.Type.CTAG }?.value
|
||||
lastKnownCTag != remoteState.value
|
||||
}
|
||||
else ->
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
override fun syncState(forceRefresh: Boolean) = useRemoteCollection { remote ->
|
||||
if (forceRefresh)
|
||||
remote.propfind(0, GetCTag.NAME, SyncToken.NAME)
|
||||
|
||||
remote.properties[SyncToken::class.java]?.token?.let {
|
||||
SyncState(SyncState.Type.SYNC_TOKEN, it)
|
||||
} ?:
|
||||
remote.properties[GetCTag::class.java]?.cTag?.let {
|
||||
SyncState(SyncState.Type.CTAG, it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun resetPresentRemotely() {
|
||||
val number = localCollection.markNotDirty(0)
|
||||
Logger.log.info("Number of local non-dirty entries: $number")
|
||||
}
|
||||
|
||||
override fun compareLocalRemote(syncState: SyncState?, remoteResources: Map<String, DavResource>): RemoteChanges {
|
||||
/* check which resources are
|
||||
1. updated remotely -> update
|
||||
2. added remotely -> update
|
||||
3. not present remotely anymore -> ignore (because they will be deleted by deleteObsolete()
|
||||
*/
|
||||
|
||||
val changes = RemoteChanges(syncState, false)
|
||||
|
||||
for ((name, remote) in remoteResources)
|
||||
useLocal(localCollection.findByName(name), { local ->
|
||||
if (local == null) {
|
||||
Logger.log.info("$name has been added remotely")
|
||||
changes.updated += remote
|
||||
} else {
|
||||
val localETag = local.eTag
|
||||
val remoteETag = remote.properties[GetETag::class.java]?.eTag ?: throw DavException("Server didn't provide ETag")
|
||||
if (localETag == remoteETag)
|
||||
Logger.log.fine("$name has not been changed on server (ETag still $remoteETag)")
|
||||
else {
|
||||
Logger.log.info("$name has been changed on server (current ETag=$remoteETag, last known ETag=$localETag)")
|
||||
changes.updated += remote
|
||||
}
|
||||
|
||||
// mark as remotely present, so that this resource won't be deleted at the end
|
||||
local.updateFlags(LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
}
|
||||
})
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
override fun listRemoteChanges(state: SyncState?): RemoteChanges {
|
||||
throw UnsupportedOperationException("Collection sync not implemented yet")
|
||||
|
||||
/* TODO
|
||||
try {
|
||||
davCollection.reportChanges(
|
||||
state?.takeIf { state.type == SyncState.Type.SYNC_TOKEN }?.value,
|
||||
false, COLLECTION_SYNC_PAGE_SIZE,
|
||||
GetETag.NAME)
|
||||
} catch(e: HttpException) {
|
||||
if (e.status in arrayOf(500,507))
|
||||
// some servers don't like the limit, try again without
|
||||
davCollection.reportChanges(
|
||||
state?.takeIf { state.type == SyncState.Type.SYNC_TOKEN }?.value,
|
||||
false, null,
|
||||
GetETag.NAME, GetContentType.NAME)
|
||||
}
|
||||
|
||||
var syncToken: String? = null
|
||||
davCollection.properties[SyncToken::class.java]?.let {
|
||||
syncToken = it.token
|
||||
}
|
||||
|
||||
val changes = RemoteChanges(syncToken?.let { SyncState(SyncState.Type.SYNC_TOKEN, it) }, davCollection.furtherResults)
|
||||
for (member in davCollection.members)
|
||||
changes.updated += member
|
||||
|
||||
for (member in davCollection.removedMembers)
|
||||
changes.deleted += member.fileName()
|
||||
|
||||
Logger.log.log(Level.INFO, "Received list of changed/removed resources", changes)
|
||||
return changes
|
||||
*/
|
||||
}
|
||||
|
||||
override fun deleteNotPresentRemotely() {
|
||||
val removed = localCollection.removeNotDirtyMarked(0)
|
||||
Logger.log.info("Removed $removed local resources which are not present on the server anymore")
|
||||
}
|
||||
|
||||
override fun postProcess() {
|
||||
}
|
||||
|
||||
|
||||
protected fun<T: LocalResource<*>?, R> useLocal(local: T, body: (T) -> R): R {
|
||||
local?.let { currentLocalResource.push(it) }
|
||||
val result = body(local)
|
||||
local?.let { currentLocalResource.pop() }
|
||||
return result
|
||||
}
|
||||
|
||||
protected fun<T: DavResource, R> useRemote(remote: T, body: (T) -> R): R {
|
||||
currentRemoteResource.push(remote)
|
||||
val result = body(remote)
|
||||
currentRemoteResource.pop()
|
||||
return result
|
||||
}
|
||||
|
||||
protected fun<R> useRemoteCollection(body: (RemoteType) -> R) =
|
||||
useRemote(davCollection, body)
|
||||
|
||||
}
|
||||
@@ -12,26 +12,30 @@ 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.exception.DavException
|
||||
import at.bitfire.dav4android.property.CalendarData
|
||||
import at.bitfire.dav4android.property.GetCTag
|
||||
import at.bitfire.dav4android.property.GetETag
|
||||
import at.bitfire.dav4android.property.SyncToken
|
||||
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.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
|
||||
|
||||
@@ -40,25 +44,16 @@ import java.util.logging.Level
|
||||
*/
|
||||
class CalendarSyncManager(
|
||||
context: Context,
|
||||
settings: ISettings,
|
||||
account: Account,
|
||||
accountSettings: AccountSettings,
|
||||
extras: Bundle,
|
||||
authority: String,
|
||||
syncResult: SyncResult,
|
||||
localCalendar: LocalCalendar
|
||||
): BaseDavSyncManager<LocalEvent, LocalCalendar, DavCalendar>(context, settings, account, accountSettings, extras, authority, syncResult, localCalendar) {
|
||||
|
||||
companion object {
|
||||
const val MULTIGET_MAX_RESOURCES = 30
|
||||
}
|
||||
|
||||
): SyncManager<LocalEvent, LocalCalendar, DavCalendar>(context, account, accountSettings, extras, authority, syncResult, localCalendar) {
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
if (!super.prepare())
|
||||
return false
|
||||
|
||||
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
|
||||
@@ -67,26 +62,39 @@ class CalendarSyncManager(
|
||||
return true
|
||||
}
|
||||
|
||||
override fun queryCapabilities() {
|
||||
useRemoteCollection { it.propfind(0, GetCTag.NAME, SyncToken.NAME) }
|
||||
}
|
||||
override fun queryCapabilities(): SyncState? =
|
||||
useRemoteCollection {
|
||||
var syncState: SyncState? = null
|
||||
it.propfind(0, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF) {
|
||||
response[SupportedReportSet::class.java]?.let { supported ->
|
||||
hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION)
|
||||
}
|
||||
|
||||
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
|
||||
syncState = syncState(response)
|
||||
}
|
||||
}
|
||||
|
||||
override fun prepareUpload(resource: LocalEvent): RequestBody = useLocal(resource, {
|
||||
Logger.log.info("Server supports Collection Sync: $hasCollectionSync")
|
||||
syncState
|
||||
}
|
||||
|
||||
override fun syncAlgorithm() = if (accountSettings.getTimeRangePastDays() != null || !hasCollectionSync)
|
||||
SyncAlgorithm.PROPFIND_REPORT
|
||||
else
|
||||
SyncAlgorithm.COLLECTION_SYNC
|
||||
|
||||
override fun prepareUpload(resource: LocalEvent): RequestBody = useLocal(resource) {
|
||||
val event = requireNotNull(resource.event)
|
||||
Logger.log.log(Level.FINE, "Preparing upload of event ${resource.fileName}", event)
|
||||
|
||||
val os = ByteArrayOutputStream()
|
||||
event.write(os)
|
||||
|
||||
RequestBody.create(
|
||||
DavCalendar.MIME_ICALENDAR_UTF8,
|
||||
os.toByteArray()
|
||||
)
|
||||
})
|
||||
os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
|
||||
}
|
||||
|
||||
override fun listAllRemote(): Map<String, DavResource> {
|
||||
override fun listAllRemote(callback: DavResponseCallback) {
|
||||
// calculate time range limits
|
||||
var limitStart: Date? = null
|
||||
accountSettings.getTimeRangePastDays()?.let { pastDays ->
|
||||
@@ -96,99 +104,81 @@ class CalendarSyncManager(
|
||||
}
|
||||
|
||||
return useRemoteCollection { remote ->
|
||||
// fetch list of remote VEVENTs and build hash table to index file name
|
||||
Logger.log.info("Querying events since $limitStart")
|
||||
remote.calendarQuery("VEVENT", limitStart, null)
|
||||
|
||||
val result = LinkedHashMap<String, DavResource>(remote.members.size)
|
||||
for (iCal in remote.members) {
|
||||
val fileName = iCal.fileName()
|
||||
Logger.log.fine("Found remote VEVENT: $fileName")
|
||||
result[fileName] = iCal
|
||||
}
|
||||
result
|
||||
remote.calendarQuery("VEVENT", limitStart, null, callback)
|
||||
}
|
||||
}
|
||||
|
||||
override fun processRemoteChanges(changes: RemoteChanges) {
|
||||
for (name in changes.deleted)
|
||||
localCollection.findByName(name)?.let {
|
||||
Logger.log.info("Deleting local event $name")
|
||||
useLocal(it, { local -> local.delete() })
|
||||
syncResult.stats.numDeletes++
|
||||
}
|
||||
|
||||
val toDownload = changes.updated.map { it.location }
|
||||
Logger.log.info("Downloading ${toDownload.size} resources ($MULTIGET_MAX_RESOURCES at once)")
|
||||
|
||||
for (bunch in toDownload.chunked(MULTIGET_MAX_RESOURCES)) {
|
||||
if (bunch.size == 1)
|
||||
// only one contact, use GET
|
||||
useRemote(DavResource(httpClient.okHttpClient, bunch.first()), { remote ->
|
||||
val body = remote.get(DavCalendar.MIME_ICALENDAR.toString())
|
||||
|
||||
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
|
||||
val eTag = remote.properties[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received CalDAV GET response without ETag for ${remote.location}")
|
||||
|
||||
body.charStream().use { reader ->
|
||||
processVEvent(remote.fileName(), eTag, reader)
|
||||
override fun downloadRemote(bunch: List<HttpUrl>) {
|
||||
Logger.log.info("Downloading ${bunch.size} iCalendars: $bunch")
|
||||
useRemoteCollection {
|
||||
it.multiget(bunch) { response, _ ->
|
||||
useRemote(response) {
|
||||
if (!response.isSuccess()) {
|
||||
Logger.log.warning("Received non-successful multiget response for ${response.href}")
|
||||
return@useRemote
|
||||
}
|
||||
})
|
||||
else {
|
||||
// multiple contacts, use multi-get
|
||||
useRemoteCollection { it.multiget(bunch) }
|
||||
|
||||
// process multiget results
|
||||
for (remote in davCollection.members)
|
||||
useRemote(remote, {
|
||||
val eTag = remote.properties[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
val eTag = response[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
|
||||
val calendarData = remote.properties[CalendarData::class.java]
|
||||
val iCalendar = calendarData?.iCalendar
|
||||
?: throw DavException("Received multi-get response without task data")
|
||||
val calendarData = response[CalendarData::class.java]
|
||||
val iCal = calendarData?.iCalendar
|
||||
?: throw DavException("Received multi-get response without address data")
|
||||
|
||||
processVEvent(remote.fileName(), eTag, StringReader(iCalendar))
|
||||
})
|
||||
processVEvent(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(iCal))
|
||||
}
|
||||
}
|
||||
|
||||
abortIfCancelled()
|
||||
}
|
||||
}
|
||||
|
||||
override fun postProcess() {
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun processVEvent(fileName: String, eTag: 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 && !event.isAllDay() && 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
|
||||
useLocal(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.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)
|
||||
useLocal(LocalEvent(localCollection, event, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) {
|
||||
it.add()
|
||||
})
|
||||
}
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
})
|
||||
}
|
||||
} else
|
||||
Logger.log.severe("Received VCALENDAR with not exactly one VEVENT with UID, but without RECURRENCE-ID; ignoring $fileName")
|
||||
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,18 +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() {
|
||||
@@ -31,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
|
||||
@@ -49,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()
|
||||
}
|
||||
}
|
||||
@@ -62,63 +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<String, CollectionInfo> {
|
||||
val collections = mutableMapOf<String, CollectionInfo>()
|
||||
service?.let {
|
||||
db.query(Collections._TABLE, null,
|
||||
"${Collections.SERVICE_ID}=? AND ${Collections.SUPPORTS_VEVENT}!=0 AND ${Collections.SYNC}",
|
||||
arrayOf(service.toString()), null, null, null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val values = ContentValues(cursor.columnCount)
|
||||
DatabaseUtils.cursorRowToContentValues(cursor, values)
|
||||
val info = CollectionInfo(values)
|
||||
collections[info.url] = info
|
||||
}
|
||||
}
|
||||
}
|
||||
return collections
|
||||
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 { url ->
|
||||
val info = remote[url]
|
||||
if (info == null) {
|
||||
Logger.log.log(Level.INFO, "Deleting obsolete local calendar", url)
|
||||
calendar.delete()
|
||||
} else {
|
||||
// remote CollectionInfo found for this local collection, update data
|
||||
Logger.log.log(Level.FINE, "Updating local calendar $url", info)
|
||||
calendar.update(info, updateColors)
|
||||
// we already have a local calendar for this remote collection, don't take into consideration anymore
|
||||
remote -= url
|
||||
}
|
||||
// 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,27 +13,29 @@ 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.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.*
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
@@ -56,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
|
||||
@@ -73,7 +75,6 @@ import java.util.logging.Level
|
||||
*/
|
||||
class ContactsSyncManager(
|
||||
context: Context,
|
||||
settings: ISettings,
|
||||
account: Account,
|
||||
accountSettings: AccountSettings,
|
||||
extras: Bundle,
|
||||
@@ -81,25 +82,24 @@ class ContactsSyncManager(
|
||||
syncResult: SyncResult,
|
||||
val provider: ContentProviderClient,
|
||||
localAddressBook: LocalAddressBook
|
||||
): BaseDavSyncManager<LocalAddress, LocalAddressBook, DavAddressBook>(context, settings, account, accountSettings, extras, authority, syncResult, localAddressBook) {
|
||||
): SyncManager<LocalAddress, LocalAddressBook, DavAddressBook>(context, account, accountSettings, extras, authority, syncResult, localAddressBook) {
|
||||
|
||||
companion object {
|
||||
private const val MULTIGET_MAX_RESOURCES = 10
|
||||
|
||||
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()
|
||||
|
||||
/**
|
||||
* Used to download images which are referenced by URL
|
||||
*/
|
||||
private lateinit var resourceDownloader: ResourceDownloader
|
||||
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
if (!super.prepare())
|
||||
return false
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
val reallyDirty = localCollection.verifyDirty()
|
||||
@@ -110,69 +110,76 @@ class ContactsSyncManager(
|
||||
}
|
||||
}
|
||||
|
||||
collectionURL = HttpUrl.parse(localCollection.url) ?: return false
|
||||
collectionURL = localCollection.url.toHttpUrlOrNull() ?: return false
|
||||
davCollection = DavAddressBook(httpClient.okHttpClient, collectionURL)
|
||||
|
||||
resourceDownloader = ResourceDownloader(davCollection.location)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun queryCapabilities() {
|
||||
useRemoteCollection { dav ->
|
||||
dav.propfind(0, SupportedAddressData.NAME, GetCTag.NAME, SyncToken.NAME)
|
||||
override fun queryCapabilities(): SyncState? {
|
||||
Logger.log.info("Contact group method: $groupMethod")
|
||||
// in case of GROUP_VCARDs, treat groups as contacts in the local address book
|
||||
localCollection.includeGroups = groupMethod == GroupMethod.GROUP_VCARDS
|
||||
|
||||
val properties = dav.properties
|
||||
properties[SupportedAddressData::class.java]?.let {
|
||||
hasVCard4 = it.hasVCard4()
|
||||
return useRemoteCollection {
|
||||
var syncState: SyncState? = null
|
||||
it.propfind(0, SupportedAddressData.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF) {
|
||||
response[SupportedAddressData::class.java]?.let { supported ->
|
||||
hasVCard4 = supported.hasVCard4()
|
||||
}
|
||||
|
||||
response[SupportedReportSet::class.java]?.let { supported ->
|
||||
hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION)
|
||||
}
|
||||
|
||||
syncState = syncState(response)
|
||||
}
|
||||
}
|
||||
Logger.log.info("Server advertises VCard/4 support: $hasVCard4")
|
||||
|
||||
Logger.log.info("Contact group method: $groupMethod")
|
||||
// in case of GROUP_VCARDs, treat groups as contacts in the local address book
|
||||
localCollection.includeGroups = groupMethod == GroupMethod.GROUP_VCARDS
|
||||
Logger.log.info("Server supports vCard/4: $hasVCard4")
|
||||
Logger.log.info("Server supports Collection Sync: $hasCollectionSync")
|
||||
|
||||
syncState
|
||||
}
|
||||
}
|
||||
|
||||
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
|
||||
override fun syncAlgorithm() = if (hasCollectionSync)
|
||||
SyncAlgorithm.COLLECTION_SYNC
|
||||
else
|
||||
SyncAlgorithm.PROPFIND_REPORT
|
||||
|
||||
override fun processLocallyDeleted(): Boolean {
|
||||
if (readOnly) {
|
||||
for (group in localCollection.findDeletedGroups()) {
|
||||
Logger.log.warning("Restoring locally deleted group (read-only address book!)")
|
||||
useLocal(group, { it.resetDeleted() })
|
||||
numDiscarded++
|
||||
}
|
||||
override fun processLocallyDeleted() =
|
||||
if (readOnly) {
|
||||
for (group in localCollection.findDeletedGroups()) {
|
||||
Logger.log.warning("Restoring locally deleted group (read-only address book!)")
|
||||
useLocal(group) { it.resetDeleted() }
|
||||
}
|
||||
|
||||
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!)")
|
||||
useLocal(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++
|
||||
useLocal(group) { it.clearDirty(null) }
|
||||
}
|
||||
|
||||
for (contact in localCollection.findDirtyContacts()) {
|
||||
Logger.log.warning("Resetting locally modified contact to ETag=null (read-only address book!)")
|
||||
useLocal(contact, { it.clearDirty(null) })
|
||||
numDiscarded++
|
||||
useLocal(contact) { it.clearDirty(null) }
|
||||
}
|
||||
|
||||
if (numDiscarded > 0)
|
||||
notifyDiscardedChange()
|
||||
|
||||
} else {
|
||||
if (groupMethod == GroupMethod.CATEGORIES) {
|
||||
/* groups memberships are represented as contact CATEGORIES */
|
||||
@@ -182,16 +189,16 @@ 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() })
|
||||
useLocal(group) { it.delete() }
|
||||
}
|
||||
|
||||
// groups with DIRTY=1: mark all memberships as dirty, then clean DIRTY flag of group
|
||||
for (group in localCollection.findDirtyGroups()) {
|
||||
Logger.log.fine("Marking members of modified group $group as dirty")
|
||||
useLocal(group, {
|
||||
useLocal(group) {
|
||||
it.markMembersDirty()
|
||||
it.clearDirty(null)
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
/* groups as separate VCards: there are group contacts and individual contacts */
|
||||
@@ -221,21 +228,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 prepareUpload(resource: LocalAddress): RequestBody = useLocal(resource) {
|
||||
val contact: Contact
|
||||
if (resource is LocalContact) {
|
||||
contact = resource.contact!!
|
||||
@@ -265,83 +258,36 @@ 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() = useRemoteCollection { dav ->
|
||||
// fetch list of remote VCards and build hash table to index file name
|
||||
dav.propfind(1, ResourceType.NAME, GetETag.NAME)
|
||||
|
||||
val result = LinkedHashMap<String, DavResource>(dav.members.size)
|
||||
for (vCard in dav.members) {
|
||||
// ignore member collections
|
||||
var ignore = false
|
||||
vCard.properties[ResourceType::class.java]?.let { type ->
|
||||
if (type.types.contains(ResourceType.COLLECTION))
|
||||
ignore = true
|
||||
}
|
||||
if (ignore)
|
||||
continue
|
||||
|
||||
val fileName = vCard.fileName()
|
||||
Logger.log.fine("Found remote VCard: $fileName")
|
||||
result[fileName] = vCard
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
override fun processRemoteChanges(changes: RemoteChanges) {
|
||||
for (name in changes.deleted)
|
||||
localCollection.findByName(name)?.let {
|
||||
Logger.log.info("Deleting local address $name")
|
||||
useLocal(it, { it.delete() })
|
||||
syncResult.stats.numDeletes++
|
||||
override fun listAllRemote(callback: DavResponseCallback) =
|
||||
useRemoteCollection {
|
||||
it.propfind(1, ResourceType.NAME, GetETag.NAME, callback = callback)
|
||||
}
|
||||
|
||||
val toDownload = changes.updated.map { it.location }
|
||||
Logger.log.info("Downloading ${toDownload.size} resources ($MULTIGET_MAX_RESOURCES at once)")
|
||||
|
||||
// prepare downloader which may be used to download external resource like contact photos
|
||||
val downloader = ResourceDownloader(collectionURL)
|
||||
|
||||
// download new/updated VCards from server
|
||||
for (bunch in toDownload.chunked(CalendarSyncManager.MULTIGET_MAX_RESOURCES)) {
|
||||
if (bunch.size == 1)
|
||||
// only one contact, use GET
|
||||
useRemote(DavResource(httpClient.okHttpClient, bunch.first()), { remote ->
|
||||
val body = remote.get("text/vcard;version=4.0, text/vcard;charset=utf-8;q=0.8, text/vcard;q=0.5")
|
||||
|
||||
// CardDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc6352#section-6.3.2.3]
|
||||
val eTag = remote.properties[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received CardDAV GET response without ETag for ${remote.location}")
|
||||
|
||||
body.charStream().use { reader ->
|
||||
processVCard(remote.fileName(), eTag, reader, downloader)
|
||||
override fun downloadRemote(bunch: List<HttpUrl>) {
|
||||
Logger.log.info("Downloading ${bunch.size} vCard(s): $bunch")
|
||||
useRemoteCollection {
|
||||
it.multiget(bunch, hasVCard4) { response, _ ->
|
||||
useRemote(response) {
|
||||
if (!response.isSuccess()) {
|
||||
Logger.log.warning("Received non-successful multiget response for ${response.href}")
|
||||
return@useRemote
|
||||
}
|
||||
})
|
||||
|
||||
else {
|
||||
// multiple contacts, use multi-get
|
||||
useRemoteCollection { it.multiget(bunch, hasVCard4) }
|
||||
val eTag = response[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
|
||||
// process multi-get results
|
||||
for (remote in davCollection.members)
|
||||
useRemote(remote, {
|
||||
val eTag = remote.properties[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")
|
||||
|
||||
val addressData = remote.properties[AddressData::class.java]
|
||||
val vCard = addressData?.vCard
|
||||
?: throw DavException("Received multi-get response without address data")
|
||||
|
||||
processVCard(remote.fileName(), eTag, StringReader(vCard), downloader)
|
||||
})
|
||||
processVCard(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(vCard), resourceDownloader)
|
||||
}
|
||||
}
|
||||
|
||||
abortIfCancelled()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,22 +311,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), {
|
||||
useLocal(localCollection.findByName(fileName)) {
|
||||
var local = it
|
||||
if (local != null) {
|
||||
Logger.log.log(Level.INFO, "Updating $fileName in local address book", newData)
|
||||
@@ -388,12 +342,14 @@ class ContactsSyncManager(
|
||||
if (local is LocalGroup && newData.group) {
|
||||
// update group
|
||||
local.eTag = eTag
|
||||
local.flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
local.update(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
|
||||
} else if (local is LocalContact && !newData.group) {
|
||||
// update contact
|
||||
local.eTag = eTag
|
||||
local.flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
local.update(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
|
||||
@@ -407,16 +363,16 @@ 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
|
||||
})
|
||||
useLocal(LocalGroup(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { group ->
|
||||
group.add()
|
||||
local = group
|
||||
}
|
||||
} else {
|
||||
Logger.log.log(Level.INFO, "Creating local contact", newData)
|
||||
useLocal(LocalContact(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT), {
|
||||
it.add()
|
||||
local = it
|
||||
})
|
||||
useLocal(LocalContact(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { contact ->
|
||||
contact.add()
|
||||
local = contact
|
||||
}
|
||||
}
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
@@ -438,9 +394,9 @@ class ContactsSyncManager(
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
(local as? LocalContact)?.updateHashCode(null)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -451,20 +407,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)
|
||||
@@ -477,7 +427,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) {
|
||||
@@ -489,4 +439,7 @@ class ContactsSyncManager(
|
||||
}
|
||||
}
|
||||
|
||||
override fun notifyInvalidResourceTitle(): String =
|
||||
context.getString(R.string.sync_invalid_contact)
|
||||
|
||||
}
|
||||
@@ -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,33 +10,63 @@ 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 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 org.apache.commons.collections4.IteratorUtils
|
||||
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
|
||||
|
||||
abstract class SyncAdapterService: Service() {
|
||||
|
||||
companion object {
|
||||
val runningSyncs: MutableSet<Pair<String, Account>> = Collections.synchronizedSet(mutableSetOf<Pair<String, Account>>())
|
||||
/** Keep a list of running syncs to block multiple calls at the same time,
|
||||
* like run by some devices. Weak references are used for the case that a thread
|
||||
* is terminated and the `finally` block which cleans up [runningSyncs] is not
|
||||
* executed. */
|
||||
private val runningSyncs = mutableListOf<WeakReference<Pair<String, Account>>>()
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -48,71 +78,95 @@ abstract class SyncAdapterService: Service() {
|
||||
context: Context
|
||||
): AbstractThreadedSyncAdapter(context, false) {
|
||||
|
||||
abstract fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult)
|
||||
companion object {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult)
|
||||
|
||||
override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
Logger.log.log(Level.INFO, "$authority sync of $account has been initiated", extras.keySet().joinToString(", "))
|
||||
|
||||
// prevent multiple syncs of the same authority to be run for the same account
|
||||
val currentSync = Pair(authority, account)
|
||||
if (!runningSyncs.add(currentSync)) {
|
||||
Logger.log.warning("There's already another $authority sync running for $account, aborting")
|
||||
return
|
||||
synchronized(runningSyncs) {
|
||||
if (runningSyncs.any { it.get() == currentSync }) {
|
||||
Logger.log.warning("There's already another $authority sync running for $account, aborting")
|
||||
return
|
||||
}
|
||||
runningSyncs += WeakReference(currentSync)
|
||||
}
|
||||
|
||||
// required for 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
|
||||
}
|
||||
|
||||
//if (runSync) {
|
||||
SyncManager.cancelNotifications(NotificationManagerCompat.from(context), authority, account)
|
||||
sync(settings, account, extras, authority, provider, syncResult)
|
||||
//}
|
||||
}
|
||||
Logger.log.info("Sync for $authority complete")
|
||||
if (true)
|
||||
sync(account, extras, authority, provider, syncResult)
|
||||
} finally {
|
||||
runningSyncs -= currentSync
|
||||
synchronized(runningSyncs) {
|
||||
runningSyncs.removeAll { it.get() == null || it.get() == currentSync }
|
||||
}
|
||||
}
|
||||
|
||||
Logger.log.info("Sync for $currentSync finished")
|
||||
}
|
||||
|
||||
override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
|
||||
Logger.log.log(Level.WARNING, "Security exception when opening content provider for $authority")
|
||||
syncResult.databaseError = true
|
||||
|
||||
val intent = Intent(context, AccountActivity::class.java)
|
||||
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
notifyPermissions(intent)
|
||||
}
|
||||
|
||||
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()) {
|
||||
// WiFi required
|
||||
val connectivityManager = context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val network = connectivityManager.activeNetworkInfo
|
||||
if (network == null || network.type != ConnectivityManager.TYPE_WIFI || !network.isConnected) {
|
||||
|
||||
// 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
|
||||
@@ -126,18 +180,6 @@ abstract class SyncAdapterService: Service() {
|
||||
return true
|
||||
}
|
||||
|
||||
private 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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -19,39 +19,52 @@ 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.DavResource
|
||||
import at.bitfire.dav4android.exception.DavException
|
||||
import at.bitfire.dav4android.exception.HttpException
|
||||
import at.bitfire.dav4android.exception.ServiceUnavailableException
|
||||
import at.bitfire.dav4android.exception.UnauthorizedException
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.DavService
|
||||
import at.bitfire.davdroid.R
|
||||
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.SyncToken
|
||||
import at.bitfire.davdroid.*
|
||||
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.MiscUtils
|
||||
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
|
||||
import org.dmfs.tasks.contract.TaskContract
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.security.cert.CertificateException
|
||||
import java.util.*
|
||||
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
|
||||
|
||||
abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionType: LocalCollection<ResourceType>>(
|
||||
@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,
|
||||
@@ -60,131 +73,191 @@ abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionTyp
|
||||
val localCollection: CollectionType
|
||||
): AutoCloseable {
|
||||
|
||||
companion object {
|
||||
|
||||
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()
|
||||
|
||||
enum class SyncAlgorithm {
|
||||
PROPFIND_REPORT,
|
||||
COLLECTION_SYNC
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MAX_MULTIGET_RESOURCES = 10
|
||||
}
|
||||
|
||||
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 = Companion.notificationTag(authority, mainAccount)
|
||||
protected val notificationTag = localCollection.tag
|
||||
|
||||
/** Local resource we're currently operating on. Used for error notifications. **/
|
||||
protected val currentLocalResource = LinkedList<LocalResource<*>>()
|
||||
/** Remote resource we're currently operating on. Used for error notifications. **/
|
||||
protected val currentRemoteResource = LinkedList<DavResource>()
|
||||
protected val httpClient = HttpClient.Builder(context, accountSettings).build()
|
||||
|
||||
protected lateinit var collectionURL: HttpUrl
|
||||
protected lateinit var davCollection: RemoteType
|
||||
|
||||
protected var hasCollectionSync = false
|
||||
|
||||
override fun close() {
|
||||
httpClient.close()
|
||||
}
|
||||
|
||||
|
||||
fun performSync() {
|
||||
// dismiss previous error notifications
|
||||
notificationManager.cancel(notificationTag, NotificationUtils.NOTIFY_SYNC_ERROR)
|
||||
|
||||
try {
|
||||
unwrapExceptions({
|
||||
Logger.log.info("Preparing synchronization")
|
||||
if (!prepare()) {
|
||||
Logger.log.info("No reason to synchronize, aborting")
|
||||
return
|
||||
return@unwrapExceptions
|
||||
}
|
||||
abortIfCancelled()
|
||||
|
||||
Logger.log.info("Querying server capabilities")
|
||||
queryCapabilities()
|
||||
abortIfCancelled()
|
||||
var remoteSyncState = queryCapabilities()
|
||||
|
||||
Logger.log.info("Sending local deletes/updates to server")
|
||||
val modificationsSent =
|
||||
processLocallyDeleted() ||
|
||||
val modificationsSent = processLocallyDeleted() ||
|
||||
uploadDirty()
|
||||
abortIfCancelled()
|
||||
|
||||
if (modificationsSent || syncRequired())
|
||||
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()) {
|
||||
SyncAlgorithm.PROPFIND_REPORT -> {
|
||||
Logger.log.info("Sync algorithm: full listing as one result (PROPFIND/REPORT)")
|
||||
resetPresentRemotely()
|
||||
|
||||
// get current sync state
|
||||
val syncState = syncState(modificationsSent)
|
||||
if (modificationsSent)
|
||||
remoteSyncState = querySyncState()
|
||||
|
||||
// list all entries at now current sync state (which may be the same as or newer than lastSyncState)
|
||||
Logger.log.info("Listing remote entries")
|
||||
val remote = listAllRemote()
|
||||
abortIfCancelled()
|
||||
|
||||
Logger.log.info("Comparing local/remote entries")
|
||||
val changes = compareLocalRemote(syncState, remote)
|
||||
|
||||
Logger.log.info("Processing remote changes")
|
||||
processRemoteChanges(changes)
|
||||
// list and process all entries at current sync state (which may be the same as or newer than remoteSyncState)
|
||||
Logger.log.info("Processing remote entries")
|
||||
syncRemote { callback ->
|
||||
listAllRemote(callback)
|
||||
}
|
||||
|
||||
Logger.log.info("Deleting entries which are not present remotely anymore")
|
||||
deleteNotPresentRemotely()
|
||||
syncResult.stats.numDeletes += deleteNotPresentRemotely()
|
||||
|
||||
Logger.log.info("Post-processing")
|
||||
postProcess()
|
||||
|
||||
Logger.log.info("Saving sync state")
|
||||
localCollection.lastSyncState = changes.state
|
||||
Logger.log.log(Level.INFO, "Saving sync state", remoteSyncState)
|
||||
localCollection.lastSyncState = remoteSyncState
|
||||
}
|
||||
SyncAlgorithm.COLLECTION_SYNC -> {
|
||||
throw UnsupportedOperationException("Collection sync not supported yet")
|
||||
var syncState = localCollection.lastSyncState?.takeIf { it.type == SyncState.Type.SYNC_TOKEN }
|
||||
|
||||
/*val lastSyncState = localCollection.getLastSyncState()?.takeIf { it.type == SyncState.Type.SYNC_TOKEN }
|
||||
val initialSync = lastSyncState == null
|
||||
if (initialSync)
|
||||
var initialSync = false
|
||||
if (syncState == null) {
|
||||
Logger.log.info("Starting initial sync")
|
||||
initialSync = true
|
||||
resetPresentRemotely()
|
||||
} else if (syncState.initialSync == true) {
|
||||
Logger.log.info("Continuing initial sync")
|
||||
initialSync = true
|
||||
}
|
||||
|
||||
var changes = listRemoteChanges(lastSyncState)
|
||||
var furtherChanges = false
|
||||
do {
|
||||
processRemoteChanges(changes)
|
||||
localCollection.setLastSyncState(changes.state)
|
||||
Logger.log.info("Listing changes since $syncState")
|
||||
syncRemote { callback ->
|
||||
try {
|
||||
val result = listRemoteChanges(syncState, callback)
|
||||
syncState = SyncState.fromSyncToken(result.first, initialSync)
|
||||
furtherChanges = result.second
|
||||
} catch(e: HttpException) {
|
||||
if (e.errors.contains(Error.VALID_SYNC_TOKEN)) {
|
||||
Logger.log.info("Sync token invalid, performing initial sync")
|
||||
initialSync = true
|
||||
resetPresentRemotely()
|
||||
|
||||
changes = listRemoteChanges(changes.state)
|
||||
} while(changes.furtherChanges)
|
||||
val result = listRemoteChanges(null, callback)
|
||||
syncState = SyncState.fromSyncToken(result.first, initialSync)
|
||||
furtherChanges = result.second
|
||||
} else
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
if (initialSync)
|
||||
Logger.log.log(Level.INFO, "Saving sync state", syncState)
|
||||
localCollection.lastSyncState = syncState
|
||||
|
||||
Logger.log.info("Server has further changes: $furtherChanges")
|
||||
} while(furtherChanges)
|
||||
|
||||
if (initialSync) {
|
||||
// initial sync is finished, remove all local resources which have not been listed by server
|
||||
Logger.log.info("Deleting local resources which are not on server (anymore)")
|
||||
deleteNotPresentRemotely()
|
||||
|
||||
postProcess()*/
|
||||
// remove initial sync flag
|
||||
syncState!!.initialSync = false
|
||||
Logger.log.log(Level.INFO, "Initial sync completed, saving sync state", syncState)
|
||||
localCollection.lastSyncState = syncState
|
||||
}
|
||||
|
||||
Logger.log.info("Post-processing")
|
||||
postProcess()
|
||||
}
|
||||
}
|
||||
else
|
||||
Logger.log.info("Remote collection didn't change, no reason to sync")
|
||||
|
||||
}
|
||||
// sync was cancelled: re-throw to SyncAdapterService
|
||||
catch(e: InterruptedException) { throw e }
|
||||
catch (e: InterruptedIOException) { throw e }
|
||||
}, { e, local, remote ->
|
||||
when (e) {
|
||||
// sync was cancelled: re-throw to SyncAdapterService
|
||||
is InterruptedException,
|
||||
is InterruptedIOException ->
|
||||
throw e
|
||||
|
||||
// I/O errors
|
||||
catch (e: SSLHandshakeException) {
|
||||
Logger.log.log(Level.WARNING, "SSL handshake failed", e)
|
||||
// specific I/O errors
|
||||
is SSLHandshakeException -> {
|
||||
Logger.log.log(Level.WARNING, "SSL handshake failed", e)
|
||||
|
||||
// when a certificate is rejected by cert4android, the cause will be a CertificateException
|
||||
if (!BuildConfig.customCerts || e.cause !is CertificateException)
|
||||
notifyException(e)
|
||||
}
|
||||
// when a certificate is rejected by cert4android, the cause will be a CertificateException
|
||||
if (e.cause !is CertificateException)
|
||||
notifyException(e, local, remote)
|
||||
}
|
||||
|
||||
// specific HTTP errors
|
||||
catch (e: ServiceUnavailableException) {
|
||||
Logger.log.log(Level.WARNING, "Got 503 Service unavailable, trying again later", e)
|
||||
e.retryAfter?.let { retryAfter ->
|
||||
// how many seconds to wait? getTime() returns ms, so divide by 1000
|
||||
syncResult.delayUntil = (retryAfter.time - Date().time) / 1000
|
||||
// specific HTTP errors
|
||||
is ServiceUnavailableException -> {
|
||||
Logger.log.log(Level.WARNING, "Got 503 Service unavailable, trying again later", e)
|
||||
e.retryAfter?.let { retryAfter ->
|
||||
// how many seconds to wait? getTime() returns ms, so divide by 1000
|
||||
syncResult.delayUntil = (retryAfter.time - Date().time) / 1000
|
||||
}
|
||||
}
|
||||
|
||||
// all others
|
||||
else ->
|
||||
notifyException(e, local, remote)
|
||||
}
|
||||
}
|
||||
|
||||
// all others
|
||||
catch (e: Throwable) { notifyException(e) }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -193,24 +266,158 @@ abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionTyp
|
||||
/**
|
||||
* Queries the server for synchronization capabilities like specific report types,
|
||||
* data formats etc.
|
||||
*
|
||||
* Should also query and save the initial sync state (e.g. CTag/sync-token).
|
||||
*
|
||||
* @return current sync state
|
||||
*/
|
||||
protected abstract fun queryCapabilities()
|
||||
protected abstract fun queryCapabilities(): SyncState?
|
||||
|
||||
protected abstract fun processLocallyDeleted(): Boolean
|
||||
protected abstract fun uploadDirty(): Boolean
|
||||
/**
|
||||
* Processes locally deleted entries and forwards them to the server (HTTP `DELETE`).
|
||||
*
|
||||
* @return whether resources have been deleted from the server
|
||||
*/
|
||||
protected open fun processLocallyDeleted(): Boolean {
|
||||
var numDeleted = 0
|
||||
|
||||
// Remove locally deleted entries from server (if they have a name, i.e. if they were uploaded before),
|
||||
// but only if they don't have changed on the server. Then finally remove them from the local address book.
|
||||
val localList = localCollection.findDeleted()
|
||||
for (local in localList) {
|
||||
useLocal(local) {
|
||||
val fileName = local.fileName
|
||||
if (fileName != null) {
|
||||
Logger.log.info("$fileName has been deleted locally -> deleting from server (ETag ${local.eTag})")
|
||||
|
||||
useRemote(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build())) { remote ->
|
||||
try {
|
||||
remote.delete(local.eTag) {}
|
||||
numDeleted++
|
||||
} catch (e: HttpException) {
|
||||
Logger.log.warning("Couldn't delete $fileName from server; ignoring (may be downloaded again)")
|
||||
}
|
||||
}
|
||||
} else
|
||||
Logger.log.info("Removing local record #${local.id} which has been deleted locally and was never uploaded")
|
||||
local.delete()
|
||||
syncResult.stats.numDeletes++
|
||||
}
|
||||
}
|
||||
Logger.log.info("Removed $numDeleted record(s) from server")
|
||||
return numDeleted > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads locally modified resources to the server (HTTP `PUT`).
|
||||
*
|
||||
* @return whether resources have been uploaded
|
||||
*/
|
||||
protected open fun uploadDirty(): Boolean {
|
||||
var numUploaded = 0
|
||||
|
||||
// make sure all resources have file name and UID before uploading them
|
||||
for (local in localCollection.findDirtyWithoutNameOrUid())
|
||||
useLocal(local) {
|
||||
Logger.log.fine("Generating file name/UID for local resource #${local.id}")
|
||||
local.assignNameAndUID()
|
||||
}
|
||||
|
||||
// upload dirty resources (parallelized)
|
||||
runBlocking(workDispatcher) {
|
||||
for (local in localCollection.findDirty())
|
||||
launch {
|
||||
useLocal(local) {
|
||||
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 = { response ->
|
||||
response.header("ETag")?.let { getETag ->
|
||||
eTag = GetETag(getETag).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: 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(eTag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Logger.log.info("Sent $numUploaded record(s) to server")
|
||||
return numUploaded > 0
|
||||
}
|
||||
|
||||
protected abstract fun prepareUpload(resource: ResourceType): RequestBody
|
||||
|
||||
/**
|
||||
* Determines whether a sync is required because there were changes on the server.
|
||||
* For instance, this method can check the collection's CTag/sync-token.
|
||||
* For instance, this method can compare the collection's `CTag`/`sync-token` with
|
||||
* the last known local value.
|
||||
*
|
||||
* When local changes have been uploaded ([processLocallyDeleted] and/or
|
||||
* [uploadDirty] were true), a sync is always required and this method
|
||||
* will not be evaluated.
|
||||
* should *not* be evaluated.
|
||||
*
|
||||
* @return whether data has been changed on the server = whether running the
|
||||
* sync algorithm is required
|
||||
* 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 abstract fun syncRequired(): Boolean
|
||||
protected open fun syncRequired(state: SyncState?): Boolean {
|
||||
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")
|
||||
return when {
|
||||
state?.type == SyncState.Type.SYNC_TOKEN -> {
|
||||
val lastKnownToken = localState?.takeIf { it.type == SyncState.Type.SYNC_TOKEN }?.value
|
||||
lastKnownToken != state.value
|
||||
}
|
||||
state?.type == SyncState.Type.CTAG -> {
|
||||
val lastKnownCTag = localState?.takeIf { it.type == SyncState.Type.CTAG }?.value
|
||||
lastKnownCTag != state.value
|
||||
}
|
||||
else ->
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines which sync algorithm to use.
|
||||
@@ -221,15 +428,6 @@ abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionTyp
|
||||
*/
|
||||
protected abstract fun syncAlgorithm(): SyncAlgorithm
|
||||
|
||||
/**
|
||||
* Returns the current sync state of the remote resource. Keep in mind that
|
||||
* WebDAV operations are atomic and the sync state might already be obsolete when used.
|
||||
*
|
||||
* @param forceRefresh false: result may be taken from a previous request, for instance
|
||||
* from the [prepare] phase; true: sends a request to determine the current sync state
|
||||
*/
|
||||
protected abstract fun syncState(forceRefresh: Boolean): SyncState?
|
||||
|
||||
/**
|
||||
* Marks all local resources which shall be taken into consideration for this
|
||||
* sync as "synchronizing". Purpose of marking is that resources which have been marked
|
||||
@@ -237,51 +435,158 @@ abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionTyp
|
||||
*
|
||||
* Used together with [deleteNotPresentRemotely].
|
||||
*/
|
||||
protected abstract fun resetPresentRemotely()
|
||||
protected open fun resetPresentRemotely() {
|
||||
val number = localCollection.markNotDirty(0)
|
||||
Logger.log.info("Number of local non-dirty entries: $number")
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all remote resources which should be taken into account for synchronization.
|
||||
* Will be used if incremental synchronization is not available.
|
||||
* @return Map with resource names (like "mycontact.vcf") as keys and the resources
|
||||
* 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 abstract fun listAllRemote(): Map<String, DavResource>
|
||||
protected open fun syncRemote(listRemote: (DavResponseCallback) -> Unit) {
|
||||
// thread-safe sync stats
|
||||
val nInserted = AtomicInteger()
|
||||
val nUpdated = AtomicInteger()
|
||||
val nDeleted = AtomicInteger()
|
||||
val nSkipped = AtomicInteger()
|
||||
|
||||
runBlocking(workDispatcher) {
|
||||
// download queue
|
||||
val toDownload = LinkedBlockingQueue<HttpUrl>()
|
||||
fun download(url: HttpUrl?) {
|
||||
if (url != null)
|
||||
toDownload += url
|
||||
|
||||
if (toDownload.size >= MAX_MULTIGET_RESOURCES || url == null) {
|
||||
while (toDownload.size > 0) {
|
||||
val bunch = LinkedList<HttpUrl>()
|
||||
toDownload.drainTo(bunch, MAX_MULTIGET_RESOURCES)
|
||||
launch {
|
||||
downloadRemote(bunch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
coroutineScope {
|
||||
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 {
|
||||
useLocal(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 {
|
||||
useLocal(localCollection.findByName(name)) { local ->
|
||||
Logger.log.info("$name has been deleted on server, deleting locally")
|
||||
local?.delete()
|
||||
nDeleted.incrementAndGet()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// download remaining resources
|
||||
download(null)
|
||||
}
|
||||
|
||||
// update sync stats
|
||||
with(syncResult.stats) {
|
||||
numInserts += nInserted.get()
|
||||
numUpdates += nUpdated.get()
|
||||
numDeletes += nDeleted.get()
|
||||
numSkippedEntries += nSkipped.get()
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract fun listAllRemote(callback: DavResponseCallback)
|
||||
|
||||
protected open fun listRemoteChanges(syncState: SyncState?, callback: DavResponseCallback): Pair<SyncToken, Boolean> {
|
||||
var furtherResults = false
|
||||
|
||||
val report = davCollection.reportChanges(
|
||||
syncState?.takeIf { syncState.type == SyncState.Type.SYNC_TOKEN }?.value,
|
||||
false, null,
|
||||
GetETag.NAME) { response, relation ->
|
||||
when (relation) {
|
||||
Response.HrefRelation.SELF ->
|
||||
furtherResults = response.status?.code == 507
|
||||
|
||||
Response.HrefRelation.MEMBER ->
|
||||
callback(response, relation)
|
||||
|
||||
else ->
|
||||
Logger.log.fine("Unexpected sync-collection response: $response")
|
||||
}
|
||||
}
|
||||
|
||||
var syncToken: SyncToken? = null
|
||||
report.filterIsInstance(SyncToken::class.java).firstOrNull()?.let {
|
||||
syncToken = it
|
||||
}
|
||||
if (syncToken == null)
|
||||
throw DavException("Received sync-collection response without sync-token")
|
||||
|
||||
return Pair(syncToken!!, furtherResults)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares local resources which are marked for synchronization and remote resources by file name and ETag.
|
||||
* Remote resources
|
||||
* + which are not present locally
|
||||
* + whose ETag has changed since the last sync (i.e. remote ETag != locally known last remote ETag)
|
||||
* will be saved as "updated" in the result.
|
||||
* Downloads and processes resources, given as a list of URLs. Will be called with a list
|
||||
* of changed/new remote resources.
|
||||
*
|
||||
* Must mark all found remote resources as "present remotely", so that a later execution of
|
||||
* [deleteNotPresentRemotely] doesn't (locally) delete any currently available remote resources.
|
||||
* Implementations should not use GET to fetch single resources, but always multi-get, even
|
||||
* for single resources for these reasons:
|
||||
*
|
||||
* @param remoteResources Map of remote resource names and resources
|
||||
* @return List of updated resources on the server. The "deleted" list remains empty. The sync
|
||||
* state is taken from [syncState].
|
||||
* 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 compareLocalRemote(syncState: SyncState?, remoteResources: Map<String, DavResource>): RemoteChanges
|
||||
|
||||
/**
|
||||
* Lists remote changes (incremental sync).
|
||||
*
|
||||
* Must mark all found remote resources as "present remotely", so that a later execution of
|
||||
* [deleteNotPresentRemotely] doesn't (locally) delete any currently available remote resources.
|
||||
*
|
||||
* @return List of of remote changes together with the sync state after those changes
|
||||
*/
|
||||
protected abstract fun listRemoteChanges(state: SyncState?): RemoteChanges
|
||||
|
||||
/**
|
||||
* Processes remote changes:
|
||||
* + downloads and locally saves remotely updated resources
|
||||
* + locally deletes remotely deleted resources
|
||||
* Should call [abortIfCancelled] from time to time, for instance
|
||||
* after downloading a resource.
|
||||
* @param changes List of remotely updated and deleted resources
|
||||
*/
|
||||
protected abstract fun processRemoteChanges(changes: RemoteChanges)
|
||||
protected abstract fun downloadRemote(bunch: List<HttpUrl>)
|
||||
|
||||
/**
|
||||
* Locally deletes entries which are
|
||||
@@ -291,7 +596,11 @@ abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionTyp
|
||||
* 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 abstract fun deleteNotPresentRemotely()
|
||||
protected open fun deleteNotPresentRemotely(): Int {
|
||||
val removed = localCollection.removeNotDirtyMarked(0)
|
||||
Logger.log.info("Removed $removed local resources which are not present on the server anymore")
|
||||
return removed
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-processing of synchronized entries, for instance contact group membership operations.
|
||||
@@ -299,21 +608,34 @@ abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionTyp
|
||||
protected abstract fun postProcess()
|
||||
|
||||
|
||||
/**
|
||||
* Throws an [InterruptedException] if the current thread has been interrupted,
|
||||
* most probably because synchronization was cancelled by the user.
|
||||
* @throws InterruptedException (which will be catched by [performSync])
|
||||
* */
|
||||
protected fun abortIfCancelled() {
|
||||
if (Thread.interrupted())
|
||||
throw InterruptedException("Sync was cancelled")
|
||||
// sync helpers
|
||||
|
||||
protected fun syncState(dav: Response) =
|
||||
dav[SyncToken::class.java]?.token?.let {
|
||||
SyncState(SyncState.Type.SYNC_TOKEN, it)
|
||||
} ?:
|
||||
dav[GetCTag::class.java]?.cTag?.let {
|
||||
SyncState(SyncState.Type.CTAG, it)
|
||||
}
|
||||
|
||||
private fun querySyncState(): SyncState? {
|
||||
var state: SyncState? = null
|
||||
davCollection.propfind(0, GetCTag.NAME, SyncToken.NAME) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF)
|
||||
state = syncState(response)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
private fun notifyException(e: Throwable) {
|
||||
|
||||
// exception helpers
|
||||
|
||||
private fun notifyException(e: Throwable, local: ResourceType?, remote: HttpUrl?) {
|
||||
val message: String
|
||||
|
||||
when (e) {
|
||||
is IOException -> {
|
||||
is IOException,
|
||||
is InterruptedIOException -> {
|
||||
Logger.log.log(Level.WARNING, "I/O error", e)
|
||||
message = context.getString(R.string.sync_error_io, e.localizedMessage)
|
||||
syncResult.stats.numIoExceptions++
|
||||
@@ -330,7 +652,7 @@ abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionTyp
|
||||
}
|
||||
is CalendarStorageException, is ContactsStorageException, is RemoteException -> {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't access local storage", e)
|
||||
message = context.getString(R.string.sync_error_local_storage)
|
||||
message = context.getString(R.string.sync_error_local_storage, e.localizedMessage)
|
||||
syncResult.databaseError = true
|
||||
}
|
||||
else -> {
|
||||
@@ -343,25 +665,16 @@ abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionTyp
|
||||
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
|
||||
currentLocalResource.firstOrNull()?.let { local ->
|
||||
// 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)
|
||||
}
|
||||
currentRemoteResource.firstOrNull()?.let { remote ->
|
||||
contentIntent.putExtra(DebugInfoActivity.KEY_REMOTE_RESOURCE, remote.location.toString())
|
||||
}
|
||||
}
|
||||
|
||||
// to make the PendingIntent unique
|
||||
@@ -378,7 +691,7 @@ abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionTyp
|
||||
}
|
||||
|
||||
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))
|
||||
@@ -393,6 +706,19 @@ abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionTyp
|
||||
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
|
||||
@@ -419,7 +745,7 @@ abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionTyp
|
||||
PendingIntent.getService(context, 0, retryIntent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
}
|
||||
|
||||
private fun buildViewItemAction(local: LocalResource<*>): NotificationCompat.Action? {
|
||||
private fun buildViewItemAction(local: ResourceType): NotificationCompat.Action? {
|
||||
Logger.log.log(Level.FINE, "Adding view action for local resource", local)
|
||||
val intent = local.id?.let { id ->
|
||||
when (local) {
|
||||
@@ -441,25 +767,106 @@ abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionTyp
|
||||
}
|
||||
|
||||
|
||||
enum class SyncPhase(val number: Int) {
|
||||
PREPARE(0),
|
||||
QUERY_CAPABILITIES(1)
|
||||
@Deprecated("Use Kotlin coroutines instead")
|
||||
fun checkResults(results: MutableCollection<Future<*>>) {
|
||||
val iter = results.iterator()
|
||||
while (iter.hasNext()) {
|
||||
val result = iter.next()
|
||||
if (result.isDone) {
|
||||
try {
|
||||
result.get()
|
||||
} catch(e: ExecutionException) {
|
||||
throw e.cause!!
|
||||
}
|
||||
iter.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class SyncAlgorithm {
|
||||
PROPFIND_REPORT,
|
||||
COLLECTION_SYNC
|
||||
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
|
||||
|
||||
class RemoteChanges(
|
||||
val state: SyncState?,
|
||||
val furtherChanges: Boolean
|
||||
) {
|
||||
val deleted = LinkedList<String>()
|
||||
val updated = LinkedList<DavResource>()
|
||||
|
||||
override fun toString() = MiscUtils.reflectionToString(this)
|
||||
protected fun<T: ResourceType?, R> useLocal(local: T, body: (T) -> R): R {
|
||||
try {
|
||||
return body(local)
|
||||
} catch (e: ContextedException) {
|
||||
e.addContextValue(Constants.EXCEPTION_CONTEXT_LOCAL_RESOURCE, local)
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
if (local != null)
|
||||
throw ContextedException(e).setContextValue(Constants.EXCEPTION_CONTEXT_LOCAL_RESOURCE, local)
|
||||
else
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
protected fun<T: DavResource, R> useRemote(remote: T, body: (T) -> R): R {
|
||||
try {
|
||||
return body(remote)
|
||||
} catch (e: ContextedException) {
|
||||
e.addContextValue(Constants.EXCEPTION_CONTEXT_REMOTE_RESOURCE, remote.location)
|
||||
throw e
|
||||
} catch(e: Throwable) {
|
||||
throw ContextedException(e).setContextValue(Constants.EXCEPTION_CONTEXT_REMOTE_RESOURCE, remote.location)
|
||||
}
|
||||
}
|
||||
|
||||
protected fun<T> useRemote(remote: Response, body: (Response) -> T): T {
|
||||
try {
|
||||
return body(remote)
|
||||
} catch (e: ContextedException) {
|
||||
e.addContextValue(Constants.EXCEPTION_CONTEXT_REMOTE_RESOURCE, remote.href)
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
throw ContextedException(e).setContextValue(Constants.EXCEPTION_CONTEXT_REMOTE_RESOURCE, remote.href)
|
||||
}
|
||||
}
|
||||
|
||||
protected fun<R> useRemoteCollection(body: (RemoteType) -> R) =
|
||||
useRemote(davCollection, body)
|
||||
|
||||
private fun unwrapExceptions(body: () -> Unit, handler: (e: Throwable, local: ResourceType?, remote: HttpUrl?) -> Unit) {
|
||||
var ex: Throwable? = null
|
||||
try {
|
||||
body()
|
||||
} catch(e: Throwable) {
|
||||
ex = e
|
||||
}
|
||||
|
||||
var local: ResourceType? = null
|
||||
var remote: HttpUrl? = null
|
||||
|
||||
if (ex is ContextedException) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
// we want the innermost context value, which is the first one
|
||||
(ex.getFirstContextValue(Constants.EXCEPTION_CONTEXT_LOCAL_RESOURCE) as? ResourceType)?.let {
|
||||
if (local == null)
|
||||
local = it
|
||||
}
|
||||
(ex.getFirstContextValue(Constants.EXCEPTION_CONTEXT_REMOTE_RESOURCE) as? HttpUrl)?.let {
|
||||
if (remote == null)
|
||||
remote = it
|
||||
}
|
||||
ex = ex.cause
|
||||
}
|
||||
|
||||
if (ex != null)
|
||||
handler(ex, local, remote)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -8,27 +8,28 @@
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.database.DatabaseUtils
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import android.support.v4.app.NotificationManagerCompat
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import at.bitfire.davdroid.model.ServiceDB.Services
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.model.Service
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.ical4android.AndroidTaskList
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.dmfs.tasks.contract.TaskContract
|
||||
import java.util.logging.Level
|
||||
|
||||
@@ -44,10 +45,15 @@ class TasksSyncAdapterService: 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 taskProvider = TaskProvider.fromProviderClient(context, provider)
|
||||
val accountSettings = AccountSettings(context, settings, account)
|
||||
|
||||
// make sure account can be seen by OpenTasks
|
||||
if (Build.VERSION.SDK_INT >= 26)
|
||||
AccountManager.get(context).setAccountVisibility(account, taskProvider.name.packageName, AccountManager.VISIBILITY_VISIBLE)
|
||||
|
||||
val accountSettings = AccountSettings(context, account)
|
||||
/* 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)
|
||||
@@ -57,17 +63,21 @@ class TasksSyncAdapterService: SyncAdapterService() {
|
||||
|
||||
updateLocalTaskLists(taskProvider, account, accountSettings)
|
||||
|
||||
for (taskList in AndroidTaskList.find(account, taskProvider, LocalTaskList.Factory, "${TaskContract.TaskLists.SYNC_ENABLED}!=0", null)) {
|
||||
val priorityTaskLists = priorityCollections(extras)
|
||||
val taskLists = AndroidTaskList
|
||||
.find(account, taskProvider, LocalTaskList.Factory, "${TaskContract.TaskLists.SYNC_ENABLED}!=0", null)
|
||||
.sortedByDescending { priorityTaskLists.contains(it.id) }
|
||||
for (taskList in taskLists) {
|
||||
Logger.log.info("Synchronizing task list #${taskList.id} [${taskList.syncId}]")
|
||||
TasksSyncManager(context, settings, account, accountSettings, extras, authority, syncResult, taskList).use {
|
||||
TasksSyncManager(context, account, accountSettings, extras, authority, syncResult, taskList).use {
|
||||
it.performSync()
|
||||
}
|
||||
}
|
||||
} catch (e: TaskProvider.ProviderTooOldException) {
|
||||
val nm = NotificationManagerCompat.from(context)
|
||||
val message = context.getString(R.string.sync_error_opentasks_required_version, e.provider.minVersionName, e.installedVersionName)
|
||||
val notify = NotificationUtils.newBuilder(context)
|
||||
.setSmallIcon(R.drawable.ic_sync_error_notification)
|
||||
val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_ERRORS)
|
||||
.setSmallIcon(R.drawable.ic_sync_problem_notify)
|
||||
.setContentTitle(context.getString(R.string.sync_error_opentasks_too_old))
|
||||
.setContentText(message)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||
@@ -95,63 +105,38 @@ class TasksSyncAdapterService: SyncAdapterService() {
|
||||
}
|
||||
|
||||
private fun updateLocalTaskLists(provider: TaskProvider, 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(Services._TABLE, arrayOf(Services.ID),
|
||||
"${Services.ACCOUNT_NAME}=? AND ${Services.SERVICE}=?",
|
||||
arrayOf(account.name, Services.SERVICE_CALDAV), null, null, null)?.use { c ->
|
||||
if (c.moveToNext())
|
||||
c.getLong(0)
|
||||
else
|
||||
null
|
||||
}
|
||||
|
||||
fun remoteTaskLists(service: Long?): MutableMap<String, CollectionInfo> {
|
||||
val collections = mutableMapOf<String, CollectionInfo>()
|
||||
service?.let {
|
||||
db.query(Collections._TABLE, null,
|
||||
"${Collections.SERVICE_ID}=? AND ${Collections.SUPPORTS_VTODO}!=0 AND ${Collections.SYNC}",
|
||||
arrayOf(service.toString()), null, null, null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val values = ContentValues(cursor.columnCount)
|
||||
DatabaseUtils.cursorRowToContentValues(cursor, values)
|
||||
val info = CollectionInfo(values)
|
||||
collections[info.url] = info
|
||||
}
|
||||
}
|
||||
}
|
||||
return collections
|
||||
val remoteTaskLists = mutableMapOf<HttpUrl, Collection>()
|
||||
if (service != null)
|
||||
for (collection in db.collectionDao().getSyncTaskLists(service.id)) {
|
||||
remoteTaskLists[collection.url] = collection
|
||||
}
|
||||
|
||||
// enumerate remote and local task lists
|
||||
val service = getService()
|
||||
val remote = remoteTaskLists(service)
|
||||
// delete/update local task lists
|
||||
val updateColors = settings.getManageCalendarColors()
|
||||
|
||||
// delete/update local task lists
|
||||
val updateColors = settings.getManageCalendarColors()
|
||||
|
||||
for (list in AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null))
|
||||
list.syncId?.let { url ->
|
||||
val info = remote[url]
|
||||
if (info == null) {
|
||||
Logger.log.fine("Deleting obsolete local task list $url")
|
||||
list.delete()
|
||||
} else {
|
||||
// remote CollectionInfo found for this local collection, update data
|
||||
Logger.log.log(Level.FINE, "Updating local task list $url", info)
|
||||
list.update(info, updateColors)
|
||||
// we already have a local task list for this remote collection, don't take into consideration anymore
|
||||
remote -= url
|
||||
}
|
||||
for (list in AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null))
|
||||
list.syncId?.let {
|
||||
val url = it.toHttpUrl()
|
||||
val info = remoteTaskLists[url]
|
||||
if (info == null) {
|
||||
Logger.log.fine("Deleting obsolete local task list $url")
|
||||
list.delete()
|
||||
} else {
|
||||
// remote CollectionInfo found for this local collection, update data
|
||||
Logger.log.log(Level.FINE, "Updating local task list $url", info)
|
||||
list.update(info, updateColors)
|
||||
// we already have a local task list for this remote collection, don't take into consideration anymore
|
||||
remoteTaskLists -= url
|
||||
}
|
||||
|
||||
// create new local task lists
|
||||
for ((_,info) in remote) {
|
||||
Logger.log.log(Level.INFO, "Adding local task list", info)
|
||||
LocalTaskList.create(account, provider, info)
|
||||
}
|
||||
|
||||
// create new local task lists
|
||||
for ((_,info) in remoteTaskLists) {
|
||||
Logger.log.log(Level.INFO, "Adding local task list", info)
|
||||
LocalTaskList.create(account, provider, info)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.exception.DavException
|
||||
import at.bitfire.dav4android.property.CalendarData
|
||||
import at.bitfire.dav4android.property.GetCTag
|
||||
import at.bitfire.dav4android.property.GetETag
|
||||
import at.bitfire.dav4android.property.SyncToken
|
||||
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.CalendarData
|
||||
import at.bitfire.dav4jvm.property.GetCTag
|
||||
import at.bitfire.dav4jvm.property.GetETag
|
||||
import at.bitfire.dav4jvm.property.SyncToken
|
||||
import at.bitfire.davdroid.DavUtils
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.resource.LocalTask
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.ical4android.InvalidCalendarException
|
||||
import at.bitfire.ical4android.Task
|
||||
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.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
@@ -40,115 +44,88 @@ import java.util.logging.Level
|
||||
*/
|
||||
class TasksSyncManager(
|
||||
context: Context,
|
||||
settings: ISettings,
|
||||
account: Account,
|
||||
accountSettings: AccountSettings,
|
||||
extras: Bundle,
|
||||
authority: String,
|
||||
syncResult: SyncResult,
|
||||
localCollection: LocalTaskList
|
||||
): BaseDavSyncManager<LocalTask, LocalTaskList, DavCalendar>(context, settings, account, accountSettings, extras, authority, syncResult, localCollection) {
|
||||
|
||||
companion object {
|
||||
const val MULTIGET_MAX_RESOURCES = 30
|
||||
}
|
||||
|
||||
): SyncManager<LocalTask, LocalTaskList, DavCalendar>(context, account, accountSettings, extras, authority, syncResult, localCollection) {
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
if (!super.prepare())
|
||||
return false
|
||||
|
||||
val url = localCollection.syncId ?: return false
|
||||
collectionURL = HttpUrl.parse(url) ?: return false
|
||||
collectionURL = (localCollection.syncId ?: return false).toHttpUrlOrNull() ?: return false
|
||||
davCollection = DavCalendar(httpClient.okHttpClient, collectionURL)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun queryCapabilities() {
|
||||
useRemoteCollection { it.propfind(0, GetCTag.NAME, SyncToken.NAME) }
|
||||
}
|
||||
override fun queryCapabilities() =
|
||||
useRemoteCollection {
|
||||
var syncState: SyncState? = null
|
||||
it.propfind(0, GetCTag.NAME, SyncToken.NAME) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF)
|
||||
syncState = syncState(response)
|
||||
}
|
||||
syncState
|
||||
}
|
||||
|
||||
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
|
||||
|
||||
override fun prepareUpload(resource: LocalTask): RequestBody = useLocal(resource, {
|
||||
override fun prepareUpload(resource: LocalTask): RequestBody = useLocal(resource) {
|
||||
val task = requireNotNull(resource.task)
|
||||
Logger.log.log(Level.FINE, "Preparing upload of task ${resource.fileName}", task)
|
||||
|
||||
val os = ByteArrayOutputStream()
|
||||
task.write(os)
|
||||
|
||||
RequestBody.create(
|
||||
DavCalendar.MIME_ICALENDAR_UTF8,
|
||||
os.toByteArray()
|
||||
)
|
||||
})
|
||||
|
||||
override fun listAllRemote() = useRemoteCollection { remote ->
|
||||
remote.calendarQuery("VTODO", null, null)
|
||||
|
||||
val result = LinkedHashMap<String, DavResource>(remote.members.size)
|
||||
for (vCard in remote.members) {
|
||||
val fileName = vCard.fileName()
|
||||
Logger.log.fine("Found remote VTODO: $fileName")
|
||||
result[fileName] = vCard
|
||||
}
|
||||
result
|
||||
os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8)
|
||||
}
|
||||
|
||||
override fun processRemoteChanges(changes: RemoteChanges) {
|
||||
for (name in changes.deleted) {
|
||||
localCollection.findByName(name)?.let {
|
||||
Logger.log.info("Deleting local task $name")
|
||||
useLocal(it, { it.delete() })
|
||||
syncResult.stats.numDeletes++
|
||||
}
|
||||
override fun listAllRemote(callback: DavResponseCallback) {
|
||||
useRemoteCollection { remote ->
|
||||
Logger.log.info("Querying tasks")
|
||||
remote.calendarQuery("VTODO", null, null, callback)
|
||||
}
|
||||
}
|
||||
|
||||
val toDownload = changes.updated.map { it.location }
|
||||
Logger.log.info("Downloading ${toDownload.size} resources ($MULTIGET_MAX_RESOURCES at once)")
|
||||
|
||||
for (bunch in toDownload.chunked(MULTIGET_MAX_RESOURCES)) {
|
||||
if (bunch.size == 1)
|
||||
// only one contact, use GET
|
||||
useRemote(DavResource(httpClient.okHttpClient, bunch.first()), { remote ->
|
||||
val body = remote.get(DavCalendar.MIME_ICALENDAR.toString())
|
||||
|
||||
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
|
||||
val eTag = remote.properties[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received CalDAV GET response without ETag for ${remote.location}")
|
||||
|
||||
body.charStream().use { reader ->
|
||||
processVTodo(remote.fileName(), eTag, reader)
|
||||
override fun downloadRemote(bunch: List<HttpUrl>) {
|
||||
Logger.log.info("Downloading ${bunch.size} iCalendars: $bunch")
|
||||
// multiple iCalendars, use calendar-multi-get
|
||||
useRemoteCollection {
|
||||
it.multiget(bunch) { response, _ ->
|
||||
useRemote(response) {
|
||||
if (!response.isSuccess()) {
|
||||
Logger.log.warning("Received non-successful multiget response for ${response.href}")
|
||||
return@useRemote
|
||||
}
|
||||
})
|
||||
else {
|
||||
// multiple contacts, use multi-get
|
||||
davCollection.multiget(bunch)
|
||||
|
||||
// process multiget results
|
||||
for (remote in davCollection.members) {
|
||||
val eTag = remote.properties[GetETag::class.java]?.eTag
|
||||
val eTag = response[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
|
||||
val calendarData = remote.properties[CalendarData::class.java]
|
||||
val iCalendar = calendarData?.iCalendar
|
||||
?: throw DavException("Received multi-get response without task data")
|
||||
val calendarData = response[CalendarData::class.java]
|
||||
val iCal = calendarData?.iCalendar
|
||||
?: throw DavException("Received multi-get response without address data")
|
||||
|
||||
processVTodo(remote.fileName(), eTag, StringReader(iCalendar))
|
||||
processVTodo(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(iCal))
|
||||
}
|
||||
}
|
||||
|
||||
abortIfCancelled()
|
||||
}
|
||||
}
|
||||
|
||||
override fun postProcess() {
|
||||
val touched = localCollection.touchRelations()
|
||||
Logger.log.info("Touched $touched relations")
|
||||
}
|
||||
|
||||
// helpers
|
||||
|
||||
private fun processVTodo(fileName: String, eTag: String, reader: Reader) {
|
||||
val tasks: List<Task>
|
||||
try {
|
||||
tasks = Task.fromReader(reader)
|
||||
tasks = Task.tasksFromReader(reader)
|
||||
} catch (e: InvalidCalendarException) {
|
||||
Logger.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e)
|
||||
notifyInvalidResource(e, fileName)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -156,22 +133,25 @@ class TasksSyncManager(
|
||||
val newData = tasks.first()
|
||||
|
||||
// update local task, if it exists
|
||||
useLocal(localCollection.findByName(fileName), { local ->
|
||||
useLocal(localCollection.findByName(fileName)) { local ->
|
||||
if (local != null) {
|
||||
Logger.log.info("Updating $fileName in local task list")
|
||||
Logger.log.log(Level.INFO, "Updating $fileName in local task list", newData)
|
||||
local.eTag = eTag
|
||||
local.update(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
} else {
|
||||
Logger.log.info("Adding $fileName to local task list")
|
||||
useLocal(LocalTask(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT), {
|
||||
Logger.log.log(Level.INFO, "Adding $fileName to local task list", newData)
|
||||
useLocal(LocalTask(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) {
|
||||
it.add()
|
||||
})
|
||||
}
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
})
|
||||
}
|
||||
} else
|
||||
Logger.log.info("Received VCALENDAR with not exactly one VTODO; ignoring $fileName")
|
||||
}
|
||||
|
||||
override fun notifyInvalidResourceTitle(): String =
|
||||
context.getString(R.string.sync_invalid_task)
|
||||
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* Copyright © Ricki Hirner (bitfire web engineering) and other contributors.
|
||||
* 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
|
||||
@@ -8,208 +8,231 @@
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.Fragment
|
||||
import android.support.v4.app.FragmentManager
|
||||
import android.support.v4.app.FragmentPagerAdapter
|
||||
import android.support.v4.app.LoaderManager
|
||||
import android.support.v4.content.AsyncTaskLoader
|
||||
import android.support.v4.content.Loader
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.text.Html
|
||||
import android.text.Spanned
|
||||
import android.text.util.Linkify
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.*
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.cardview.widget.CardView
|
||||
import androidx.core.text.HtmlCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.FragmentPagerAdapter
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import ezvcard.Ezvcard
|
||||
import kotlinx.android.synthetic.main.about_component.view.*
|
||||
import com.mikepenz.aboutlibraries.Libs
|
||||
import com.mikepenz.aboutlibraries.LibsBuilder
|
||||
import kotlinx.android.synthetic.main.about.*
|
||||
import kotlinx.android.synthetic.main.about_languages.*
|
||||
import kotlinx.android.synthetic.main.about_translation.view.*
|
||||
import kotlinx.android.synthetic.main.activity_about.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.apache.commons.io.IOUtils
|
||||
import java.io.IOException
|
||||
import org.json.JSONObject
|
||||
import java.text.Collator
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
class AboutActivity: AppCompatActivity() {
|
||||
|
||||
private class ComponentInfo(
|
||||
val title: String?,
|
||||
val version: String?,
|
||||
val website: String,
|
||||
val copyright: String,
|
||||
val licenseInfo: Int?,
|
||||
val licenseTextFile: String?
|
||||
)
|
||||
companion object {
|
||||
|
||||
private lateinit var components: Array<ComponentInfo>
|
||||
const val pixelsHtml = "<font color=\"#fff433\">■</font>" +
|
||||
"<font color=\"#ffffff\">■</font>" +
|
||||
"<font color=\"#9b59d0\">■</font>" +
|
||||
"<font color=\"#000000\">■</font>"
|
||||
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
components = arrayOf(
|
||||
ComponentInfo(
|
||||
null, BuildConfig.VERSION_NAME, getString(R.string.homepage_url),
|
||||
"Ricki Hirner, Bernhard Stockmann (bitfire web engineering)",
|
||||
R.string.about_license_info_no_warranty, "gpl-3.0-standalone.html"
|
||||
), ComponentInfo(
|
||||
"AmbilWarna", null, "https://github.com/yukuku/ambilwarna",
|
||||
"Yuku", R.string.about_license_info_no_warranty, "apache2.html"
|
||||
), ComponentInfo(
|
||||
"Apache Commons", null, "http://commons.apache.org/",
|
||||
"Apache Software Foundation", R.string.about_license_info_no_warranty, "apache2.html"
|
||||
), ComponentInfo(
|
||||
"dnsjava", null, "http://dnsjava.org/",
|
||||
"Brian Wellington", R.string.about_license_info_no_warranty, "bsd.html"
|
||||
), ComponentInfo(
|
||||
"ez-vcard", Ezvcard.VERSION, "https://github.com/mangstadt/ez-vcard",
|
||||
"Michael Angstadt", R.string.about_license_info_no_warranty, "bsd.html"
|
||||
), ComponentInfo(
|
||||
"ical4j", "2.x", "https://ical4j.github.io/",
|
||||
"Ben Fortuna", R.string.about_license_info_no_warranty, "bsd-3clause.html"
|
||||
), ComponentInfo(
|
||||
"OkHttp", null, "https://square.github.io/okhttp/",
|
||||
"Square, Inc.", R.string.about_license_info_no_warranty, "apache2.html"
|
||||
)
|
||||
)
|
||||
|
||||
setContentView(R.layout.activity_about)
|
||||
|
||||
setSupportActionBar(toolbar)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
viewpager.adapter = TabsAdapter(supportFragmentManager)
|
||||
tabs.setupWithViewPager(viewpager)
|
||||
tabs.setupWithViewPager(viewpager, false)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
menuInflater.inflate(R.menu.about_davdroid, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
fun showWebsite(item: MenuItem) {
|
||||
UiUtils.launchUri(this, App.homepageUrl(this))
|
||||
}
|
||||
|
||||
|
||||
private inner class TabsAdapter(
|
||||
fm: FragmentManager
|
||||
): FragmentPagerAdapter(fm) {
|
||||
): FragmentPagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
|
||||
|
||||
override fun getCount() = components.size
|
||||
override fun getCount() = 3
|
||||
|
||||
override fun getPageTitle(position: Int) =
|
||||
components[position].title ?: getString(R.string.app_name)!!
|
||||
override fun getPageTitle(position: Int): String =
|
||||
when (position) {
|
||||
0 -> getString(R.string.app_name)
|
||||
1 -> getString(R.string.about_translations)
|
||||
else -> getString(R.string.about_libraries)
|
||||
}
|
||||
|
||||
override fun getItem(position: Int) = ComponentFragment.instantiate(position)
|
||||
override fun getItem(position: Int) =
|
||||
when (position) {
|
||||
0 -> AppFragment()
|
||||
1 -> LanguagesFragment()
|
||||
else -> {
|
||||
LibsBuilder()
|
||||
.withLicenseShown(true)
|
||||
.withAboutIconShown(false)
|
||||
|
||||
// https://github.com/mikepenz/AboutLibraries/issues/490
|
||||
.withLibraryModification("org_brotli__dec", Libs.LibraryFields.LIBRARY_NAME, "Brotli")
|
||||
.withLibraryModification("org_brotli__dec", Libs.LibraryFields.AUTHOR_NAME, "Google")
|
||||
|
||||
.supportFragment()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ComponentFragment: Fragment(), LoaderManager.LoaderCallbacks<Spanned> {
|
||||
class AppFragment: Fragment() {
|
||||
|
||||
companion object {
|
||||
const val KEY_POSITION = "position"
|
||||
const val KEY_FILE_NAME = "fileName"
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) =
|
||||
inflater.inflate(R.layout.about, container, false)!!
|
||||
|
||||
fun instantiate(position: Int): ComponentFragment {
|
||||
val frag = ComponentFragment()
|
||||
val args = Bundle(1)
|
||||
args.putInt(KEY_POSITION, position)
|
||||
frag.arguments = args
|
||||
return frag
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
app_name.setText(R.string.app_name)
|
||||
app_version.text = getString(R.string.about_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE)
|
||||
build_time.text = getString(R.string.about_build_date, SimpleDateFormat.getDateInstance().format(BuildConfig.buildTime))
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
|
||||
icon.setImageDrawable(resources.getDrawableForDensity(R.mipmap.ic_launcher, DisplayMetrics.DENSITY_XXXHIGH))
|
||||
|
||||
pixels.text = HtmlCompat.fromHtml(pixelsHtml, HtmlCompat.FROM_HTML_MODE_LEGACY)
|
||||
|
||||
if (true /* open-source version */) {
|
||||
warranty.setText(R.string.about_license_info_no_warranty)
|
||||
|
||||
val model = ViewModelProvider(this).get(TextFileModel::class.java)
|
||||
model.initialize("gplv3.html", true)
|
||||
model.htmlText.observe(viewLifecycleOwner, Observer { spanned ->
|
||||
license_text.text = spanned
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private val licenseFragmentLoader = ServiceLoader.load(ILicenseFragment::class.java)!!.firstOrNull()
|
||||
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val info = (activity as AboutActivity).components[arguments!!.getInt(KEY_POSITION)]
|
||||
|
||||
val v = inflater.inflate(R.layout.about_component, container, false)
|
||||
|
||||
var title = info.title ?: getString(R.string.app_name)
|
||||
info.version?.let { title += " ${info.version}" }
|
||||
v.title.text = title
|
||||
|
||||
v.website.autoLinkMask = Linkify.WEB_URLS
|
||||
v.website.text = info.website
|
||||
|
||||
v.copyright.text = "© ${info.copyright}"
|
||||
|
||||
if (info.licenseInfo == null && info.licenseTextFile == null) {
|
||||
// No license text, so this must be the app's tab. Show the license fragment here, if available.
|
||||
licenseFragmentLoader?.let { factory ->
|
||||
fragmentManager!!.beginTransaction()
|
||||
.add(R.id.license_fragment, factory.getFragment())
|
||||
.commit()
|
||||
}
|
||||
v.license_terms.visibility = View.GONE
|
||||
|
||||
} else {
|
||||
// show license info
|
||||
if (info.licenseInfo == null)
|
||||
v.license_info.visibility = View.GONE
|
||||
else
|
||||
v.license_info.setText(info.licenseInfo)
|
||||
|
||||
// load and format license text
|
||||
if (info.licenseTextFile == null) {
|
||||
v.license_header.visibility = View.GONE
|
||||
v.license_text.visibility = View.GONE
|
||||
} else {
|
||||
val args = Bundle(1)
|
||||
args.putString(KEY_FILE_NAME, info.licenseTextFile)
|
||||
loaderManager.initLoader(0, args, this)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) =
|
||||
LicenseLoader(activity!!, args!!.getString(KEY_FILE_NAME))
|
||||
|
||||
override fun onLoadFinished(loader: Loader<Spanned>, license: Spanned?) {
|
||||
view?.let { v ->
|
||||
v.license_text.autoLinkMask = Linkify.EMAIL_ADDRESSES or Linkify.WEB_URLS
|
||||
v.license_text.text = license
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<Spanned>) {}
|
||||
}
|
||||
|
||||
class LicenseLoader(
|
||||
context: Context,
|
||||
val fileName: String
|
||||
): AsyncTaskLoader<Spanned>(context) {
|
||||
class LanguagesFragment: Fragment() {
|
||||
|
||||
private var content: Spanned? = null
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) =
|
||||
inflater.inflate(R.layout.about_languages, container, false)!!
|
||||
|
||||
override fun onStartLoading() {
|
||||
if (content != null)
|
||||
deliverResult(content)
|
||||
else
|
||||
forceLoad()
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val model = ViewModelProvider(this).get(TextFileModel::class.java)
|
||||
model.initialize("translators.json", false)
|
||||
model.plainText.observe(viewLifecycleOwner, Observer { json ->
|
||||
val jsonTranslations = JSONObject(json)
|
||||
translators.adapter = TranslationsAdapter(jsonTranslations)
|
||||
})
|
||||
|
||||
translators.layoutManager = LinearLayoutManager(requireActivity())
|
||||
}
|
||||
|
||||
override fun loadInBackground(): Spanned? {
|
||||
Logger.log.fine("Loading license file $fileName")
|
||||
try {
|
||||
context.resources.assets.open(fileName).use {
|
||||
content = Html.fromHtml(IOUtils.toString(it, Charsets.UTF_8))
|
||||
return content
|
||||
class Translation(
|
||||
val language: String,
|
||||
val translators: Array<String>
|
||||
)
|
||||
|
||||
class TranslationsAdapter(
|
||||
jsonTranslations: JSONObject
|
||||
): RecyclerView.Adapter<TranslationsAdapter.ViewHolder>() {
|
||||
class ViewHolder(val cardView: CardView): RecyclerView.ViewHolder(cardView)
|
||||
|
||||
private val translations = LinkedList<Translation>()
|
||||
|
||||
init {
|
||||
for (langCode in jsonTranslations.keys()) {
|
||||
val jsonTranslators = jsonTranslations.getJSONArray(langCode)
|
||||
val translators = Array<String>(jsonTranslators.length()) {
|
||||
idx -> jsonTranslators.getString(idx)
|
||||
}
|
||||
|
||||
val langTag = langCode.replace('_', '-')
|
||||
val language = Locale.forLanguageTag(langTag).displayName
|
||||
translations += Translation(language, translators)
|
||||
}
|
||||
} catch(e: IOException) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't read license file", e)
|
||||
return null
|
||||
|
||||
// sort translations by localized language name
|
||||
val collator = Collator.getInstance()
|
||||
translations.sortWith(object: Comparator<Translation> {
|
||||
override fun compare(o1: Translation, o2: Translation) =
|
||||
collator.compare(o1.language, o2.language)
|
||||
})
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val tv = LayoutInflater.from(parent.context).inflate(R.layout.about_translation, parent, false) as CardView
|
||||
return ViewHolder(tv)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val translation = translations[position]
|
||||
holder.cardView.apply {
|
||||
language.text = translation.language
|
||||
val profiles = translation.translators.map { "<a href='https://www.transifex.com/user/profile/$it'>$it</a>" }
|
||||
translators.text = HtmlCompat.fromHtml(
|
||||
context.getString(R.string.about_translations_thanks, profiles.joinToString(", ")),
|
||||
HtmlCompat.FROM_HTML_MODE_COMPACT)
|
||||
translators.movementMethod = LinkMovementMethod.getInstance()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() = translations.size
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
interface ILicenseFragment {
|
||||
class TextFileModel(
|
||||
application: Application
|
||||
): AndroidViewModel(application) {
|
||||
|
||||
fun getFragment(): Fragment
|
||||
var initialized = false
|
||||
val htmlText = MutableLiveData<Spanned>()
|
||||
val plainText = MutableLiveData<String>()
|
||||
|
||||
@UiThread
|
||||
fun initialize(assetName: String, html: Boolean) {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
getApplication<Application>().resources.assets.open(assetName).use {
|
||||
val raw = IOUtils.toString(it, Charsets.UTF_8)
|
||||
if (html) {
|
||||
val spanned = HtmlCompat.fromHtml(raw, HtmlCompat.FROM_HTML_MODE_LEGACY)
|
||||
htmlText.postValue(spanned)
|
||||
} else
|
||||
plainText.postValue(raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,759 +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.Manifest
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Dialog
|
||||
import android.app.LoaderManager
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.database.DatabaseUtils
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.support.design.widget.Snackbar
|
||||
import android.support.v4.app.ActivityCompat
|
||||
import android.support.v4.app.DialogFragment
|
||||
import android.support.v4.content.ContextCompat
|
||||
import android.support.v7.app.AlertDialog
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.support.v7.widget.Toolbar
|
||||
import android.view.*
|
||||
import android.widget.*
|
||||
import at.bitfire.davdroid.DavService
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.ServiceDB.*
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import kotlinx.android.synthetic.main.account_caldav_item.view.*
|
||||
import kotlinx.android.synthetic.main.activity_account.*
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, PopupMenu.OnMenuItemClickListener, LoaderManager.LoaderCallbacks<AccountActivity.AccountInfo> {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_ACCOUNT = "account"
|
||||
|
||||
private 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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
lateinit var account: Account
|
||||
private var accountInfo: AccountInfo? = null
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// account may be a DAVdroid address book account -> use main account in this case
|
||||
account = LocalAddressBook.mainAccount(this,
|
||||
requireNotNull(intent.getParcelableExtra(EXTRA_ACCOUNT)))
|
||||
title = account.name
|
||||
|
||||
setContentView(R.layout.activity_account)
|
||||
|
||||
val icMenu = if (Build.VERSION.SDK_INT >= 21)
|
||||
getDrawable(R.drawable.ic_menu_light)
|
||||
else
|
||||
resources.getDrawable(R.drawable.ic_menu_light)
|
||||
|
||||
// CardDAV toolbar
|
||||
carddav_menu.overflowIcon = icMenu
|
||||
carddav_menu.inflateMenu(R.menu.carddav_actions)
|
||||
carddav_menu.setOnMenuItemClickListener(this)
|
||||
|
||||
// CalDAV toolbar
|
||||
caldav_menu.overflowIcon = icMenu
|
||||
caldav_menu.inflateMenu(R.menu.caldav_actions)
|
||||
caldav_menu.setOnMenuItemClickListener(this)
|
||||
|
||||
// Webcal toolbar
|
||||
webcal_menu.overflowIcon = icMenu
|
||||
webcal_menu.inflateMenu(R.menu.webcal_actions)
|
||||
webcal_menu.setOnMenuItemClickListener(this)
|
||||
|
||||
// load CardDAV/CalDAV collections
|
||||
loaderManager.initLoader(0, null, this)
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
if (grantResults.any { it == PackageManager.PERMISSION_GRANTED })
|
||||
// we've got additional permissions; try to load everything again
|
||||
reload()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.activity_account, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
|
||||
val itemRename = menu.findItem(R.id.rename_account)
|
||||
// renameAccount is available for API level 21+
|
||||
itemRename.isVisible = Build.VERSION.SDK_INT >= 21
|
||||
return super.onPrepareOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.sync_now ->
|
||||
requestSync()
|
||||
R.id.settings -> {
|
||||
val intent = Intent(this, AccountSettingsActivity::class.java)
|
||||
intent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account)
|
||||
startActivity(intent)
|
||||
}
|
||||
R.id.rename_account ->
|
||||
RenameAccountFragment.newInstance(account).show(supportFragmentManager, null)
|
||||
R.id.delete_account -> {
|
||||
AlertDialog.Builder(this)
|
||||
.setIcon(R.drawable.ic_error_dark)
|
||||
.setTitle(R.string.account_delete_confirmation_title)
|
||||
.setMessage(R.string.account_delete_confirmation_text)
|
||||
.setNegativeButton(android.R.string.no, null)
|
||||
.setPositiveButton(android.R.string.yes, { _, _ ->
|
||||
deleteAccount()
|
||||
})
|
||||
.show()
|
||||
}
|
||||
else ->
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.refresh_address_books ->
|
||||
accountInfo?.carddav?.let { carddav ->
|
||||
val intent = Intent(this, DavService::class.java)
|
||||
intent.action = DavService.ACTION_REFRESH_COLLECTIONS
|
||||
intent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, carddav.id)
|
||||
startService(intent)
|
||||
}
|
||||
R.id.create_address_book -> {
|
||||
val intent = Intent(this, CreateAddressBookActivity::class.java)
|
||||
intent.putExtra(CreateAddressBookActivity.EXTRA_ACCOUNT, account)
|
||||
startActivity(intent)
|
||||
}
|
||||
R.id.refresh_calendars ->
|
||||
accountInfo?.caldav?.let { caldav ->
|
||||
val intent = Intent(this, DavService::class.java)
|
||||
intent.action = DavService.ACTION_REFRESH_COLLECTIONS
|
||||
intent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, caldav.id)
|
||||
startService(intent)
|
||||
}
|
||||
R.id.create_calendar -> {
|
||||
val intent = Intent(this, CreateCalendarActivity::class.java)
|
||||
intent.putExtra(CreateCalendarActivity.EXTRA_ACCOUNT, account)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
private val onItemClickListener = AdapterView.OnItemClickListener { parent, view, position, _ ->
|
||||
if (!view.isEnabled)
|
||||
return@OnItemClickListener
|
||||
|
||||
val list = parent as ListView
|
||||
val adapter = list.adapter as ArrayAdapter<CollectionInfo>
|
||||
val info = adapter.getItem(position)
|
||||
val nowChecked = !info.selected
|
||||
|
||||
OpenHelper(this).use { dbHelper ->
|
||||
val db = dbHelper.writableDatabase
|
||||
db.beginTransactionNonExclusive()
|
||||
|
||||
val values = ContentValues(1)
|
||||
values.put(Collections.SYNC, if (nowChecked) 1 else 0)
|
||||
db.update(Collections._TABLE, values, "${Collections.ID}=?", arrayOf(info.id.toString()))
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
db.endTransaction()
|
||||
}
|
||||
|
||||
info.selected = nowChecked
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private val onActionOverflowListener = { anchor: View, info: CollectionInfo ->
|
||||
val popup = PopupMenu(this, anchor, Gravity.RIGHT)
|
||||
popup.inflate(R.menu.account_collection_operations)
|
||||
|
||||
with(popup.menu.findItem(R.id.force_read_only)) {
|
||||
if (info.readOnly)
|
||||
isVisible = false
|
||||
else
|
||||
isChecked = info.forceReadOnly
|
||||
}
|
||||
|
||||
popup.setOnMenuItemClickListener({ item ->
|
||||
when (item.itemId) {
|
||||
R.id.force_read_only -> {
|
||||
val nowChecked = !item.isChecked
|
||||
OpenHelper(this).use { dbHelper ->
|
||||
val db = dbHelper.writableDatabase
|
||||
db.beginTransactionNonExclusive()
|
||||
|
||||
val values = ContentValues(1)
|
||||
values.put(Collections.FORCE_READ_ONLY, nowChecked)
|
||||
db.update(Collections._TABLE, values, "${Collections.ID}=?", arrayOf(info.id.toString()))
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
db.endTransaction()
|
||||
reload()
|
||||
}
|
||||
}
|
||||
R.id.delete_collection ->
|
||||
DeleteCollectionFragment.ConfirmDeleteCollectionFragment.newInstance(account, info).show(supportFragmentManager, null)
|
||||
}
|
||||
true
|
||||
})
|
||||
popup.show()
|
||||
|
||||
// long click was handled
|
||||
true
|
||||
}
|
||||
|
||||
private val webcalOnItemClickListener = AdapterView.OnItemClickListener { parent, _, position, _ ->
|
||||
val info = parent.getItemAtPosition(position) as CollectionInfo
|
||||
var uri = Uri.parse(info.source)
|
||||
|
||||
val nowChecked = !info.selected
|
||||
if (nowChecked) {
|
||||
// subscribe to Webcal feed
|
||||
when {
|
||||
uri.scheme.equals("http", true) -> uri = uri.buildUpon().scheme("webcal").build()
|
||||
uri.scheme.equals("https", true) -> uri = uri.buildUpon().scheme("webcals").build()
|
||||
}
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW, uri)
|
||||
info.displayName?.let { intent.putExtra("title", it) }
|
||||
info.color?.let { intent.putExtra("color", it) }
|
||||
if (packageManager.resolveActivity(intent, 0) != null)
|
||||
startActivity(intent)
|
||||
else {
|
||||
val snack = Snackbar.make(parent, R.string.account_no_webcal_handler_found, Snackbar.LENGTH_LONG)
|
||||
|
||||
val installIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=at.bitfire.icsdroid"))
|
||||
if (packageManager.resolveActivity(installIntent, 0) != null)
|
||||
snack.setAction(R.string.account_install_icsdroid, {
|
||||
startActivity(installIntent)
|
||||
})
|
||||
|
||||
snack.show()
|
||||
}
|
||||
} else {
|
||||
// unsubscribe from Webcal feed
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED)
|
||||
contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider ->
|
||||
try {
|
||||
provider.delete(CalendarContract.Calendars.CONTENT_URI, "${CalendarContract.Calendars.NAME}=?", arrayOf(info.source))
|
||||
reload()
|
||||
} finally {
|
||||
provider.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* LOADERS AND LOADED DATA */
|
||||
|
||||
class AccountInfo {
|
||||
var carddav: ServiceInfo? = null
|
||||
var caldav: ServiceInfo? = null
|
||||
|
||||
class ServiceInfo {
|
||||
var id: Long? = null
|
||||
var refreshing = false
|
||||
|
||||
var hasHomeSets = false
|
||||
var collections = listOf<CollectionInfo>()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) =
|
||||
AccountLoader(this, account)
|
||||
|
||||
fun reload() {
|
||||
loaderManager.restartLoader(0, null, this)
|
||||
}
|
||||
|
||||
override fun onLoadFinished(loader: Loader<AccountInfo>, info: AccountInfo?) {
|
||||
accountInfo = info
|
||||
|
||||
carddav.visibility = info?.carddav?.let { carddav ->
|
||||
carddav_refreshing.visibility = if (carddav.refreshing) View.VISIBLE else View.GONE
|
||||
|
||||
address_books.isEnabled = !carddav.refreshing
|
||||
address_books.alpha = if (carddav.refreshing) 0.5f else 1f
|
||||
|
||||
carddav_menu.menu.findItem(R.id.create_address_book).isEnabled = carddav.hasHomeSets
|
||||
|
||||
val adapter = AddressBookAdapter(this)
|
||||
adapter.addAll(carddav.collections)
|
||||
address_books.adapter = adapter
|
||||
address_books.onItemClickListener = onItemClickListener
|
||||
|
||||
View.VISIBLE
|
||||
} ?: View.GONE
|
||||
|
||||
caldav.visibility = info?.caldav?.let { caldav ->
|
||||
caldav_refreshing.visibility = if (caldav.refreshing) View.VISIBLE else View.GONE
|
||||
|
||||
calendars.isEnabled = !caldav.refreshing
|
||||
calendars.alpha = if (caldav.refreshing) 0.5f else 1f
|
||||
|
||||
caldav_menu.menu.findItem(R.id.create_calendar).isEnabled = caldav.hasHomeSets
|
||||
|
||||
val adapter = CalendarAdapter(this)
|
||||
adapter.addAll(caldav.collections.filter { it.type == CollectionInfo.Type.CALENDAR })
|
||||
calendars.adapter = adapter
|
||||
calendars.onItemClickListener = onItemClickListener
|
||||
|
||||
View.VISIBLE
|
||||
} ?: View.GONE
|
||||
|
||||
webcal.visibility = info?.caldav?.let {
|
||||
val collections = it.collections.filter { it.type == CollectionInfo.Type.WEBCAL }
|
||||
|
||||
val adapter = CalendarAdapter(this)
|
||||
adapter.addAll(collections)
|
||||
webcals.adapter = adapter
|
||||
webcals.onItemClickListener = webcalOnItemClickListener
|
||||
|
||||
if (collections.isNotEmpty())
|
||||
View.VISIBLE
|
||||
else
|
||||
View.GONE
|
||||
} ?: View.GONE
|
||||
|
||||
// ask for permissions
|
||||
val requiredPermissions = mutableSetOf<String>()
|
||||
info?.carddav?.let { carddav ->
|
||||
if (carddav.collections.any { it.type == CollectionInfo.Type.ADDRESS_BOOK }) {
|
||||
requiredPermissions += Manifest.permission.READ_CONTACTS
|
||||
requiredPermissions += Manifest.permission.WRITE_CONTACTS
|
||||
}
|
||||
}
|
||||
|
||||
info?.caldav?.let { caldav ->
|
||||
if (caldav.collections.any { it.type == CollectionInfo.Type.CALENDAR }) {
|
||||
requiredPermissions += Manifest.permission.READ_CALENDAR
|
||||
requiredPermissions += Manifest.permission.WRITE_CALENDAR
|
||||
|
||||
if (LocalTaskList.tasksProviderAvailable(this)) {
|
||||
requiredPermissions += TaskProvider.PERMISSION_READ_TASKS
|
||||
requiredPermissions += TaskProvider.PERMISSION_WRITE_TASKS
|
||||
}
|
||||
}
|
||||
if (caldav.collections.any { it.type == CollectionInfo.Type.WEBCAL })
|
||||
requiredPermissions += Manifest.permission.READ_CALENDAR
|
||||
}
|
||||
|
||||
val askPermissions = requiredPermissions.filter { ActivityCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED }
|
||||
if (askPermissions.isNotEmpty())
|
||||
ActivityCompat.requestPermissions(this, askPermissions.toTypedArray(), 0)
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<AccountInfo>) {
|
||||
address_books?.adapter = null
|
||||
calendars?.adapter = null
|
||||
}
|
||||
|
||||
|
||||
class AccountLoader(
|
||||
context: Context,
|
||||
val account: Account
|
||||
): AsyncTaskLoader<AccountInfo>(context), DavService.RefreshingStatusListener, SyncStatusObserver {
|
||||
|
||||
private var syncStatusListener: Any? = null
|
||||
|
||||
private var davServiceConn: ServiceConnection? = null
|
||||
private var davService: DavService.InfoBinder? = null
|
||||
|
||||
override fun onStartLoading() {
|
||||
// get notified when sync status changes
|
||||
if (syncStatusListener == null)
|
||||
syncStatusListener = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE, this)
|
||||
|
||||
// bind to DavService to get notified when it's running
|
||||
if (davServiceConn == null) {
|
||||
davServiceConn = object: ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName, service: IBinder) {
|
||||
// get notified when DavService is running
|
||||
davService = service as DavService.InfoBinder
|
||||
service.addRefreshingStatusListener(this@AccountLoader, false)
|
||||
|
||||
onContentChanged()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName) {
|
||||
davService = null
|
||||
}
|
||||
}
|
||||
context.bindService(Intent(context, DavService::class.java), davServiceConn, Context.BIND_AUTO_CREATE)
|
||||
} else
|
||||
forceLoad()
|
||||
}
|
||||
|
||||
override fun onReset() {
|
||||
ContentResolver.removeStatusChangeListener(syncStatusListener)
|
||||
|
||||
davService?.removeRefreshingStatusListener(this)
|
||||
davServiceConn?.let {
|
||||
context.unbindService(it)
|
||||
davServiceConn = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDavRefreshStatusChanged(id: Long, refreshing: Boolean) =
|
||||
onContentChanged()
|
||||
|
||||
override fun onStatusChanged(which: Int) =
|
||||
onContentChanged()
|
||||
|
||||
override fun loadInBackground(): AccountInfo {
|
||||
val info = AccountInfo()
|
||||
|
||||
OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.readableDatabase
|
||||
db.query(
|
||||
Services._TABLE,
|
||||
arrayOf(Services.ID, Services.SERVICE),
|
||||
"${Services.ACCOUNT_NAME}=?", arrayOf(account.name),
|
||||
null, null, null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(0)
|
||||
when (cursor.getString(1)) {
|
||||
Services.SERVICE_CARDDAV -> {
|
||||
val carddav = AccountInfo.ServiceInfo()
|
||||
info.carddav = carddav
|
||||
carddav.id = id
|
||||
carddav.refreshing =
|
||||
davService?.isRefreshing(id) ?: false ||
|
||||
ContentResolver.isSyncActive(account, context.getString(R.string.address_books_authority))
|
||||
|
||||
val accountManager = AccountManager.get(context)
|
||||
for (addrBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) {
|
||||
val addressBook = LocalAddressBook(context, addrBookAccount, null)
|
||||
try {
|
||||
if (account == addressBook.mainAccount)
|
||||
carddav.refreshing = carddav.refreshing || ContentResolver.isSyncActive(addrBookAccount, ContactsContract.AUTHORITY)
|
||||
} catch(e: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
carddav.hasHomeSets = hasHomeSets(db, id)
|
||||
carddav.collections = readCollections(db, id)
|
||||
}
|
||||
Services.SERVICE_CALDAV -> {
|
||||
val caldav = AccountInfo.ServiceInfo()
|
||||
info.caldav = caldav
|
||||
caldav.id = id
|
||||
caldav.refreshing =
|
||||
davService?.isRefreshing(id) ?: false ||
|
||||
ContentResolver.isSyncActive(account, CalendarContract.AUTHORITY) ||
|
||||
ContentResolver.isSyncActive(account, TaskProvider.ProviderName.OpenTasks.authority)
|
||||
caldav.hasHomeSets = hasHomeSets(db, id)
|
||||
caldav.collections = readCollections(db, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
private fun hasHomeSets(db: SQLiteDatabase, service: Long): Boolean {
|
||||
db.query(ServiceDB.HomeSets._TABLE, null, "${ServiceDB.HomeSets.SERVICE_ID}=?",
|
||||
arrayOf(service.toString()), null, null, null)?.use { cursor ->
|
||||
return cursor.count > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun readCollections(db: SQLiteDatabase, service: Long): List<CollectionInfo> {
|
||||
val collections = LinkedList<CollectionInfo>()
|
||||
db.query(Collections._TABLE, null, Collections.SERVICE_ID + "=?", arrayOf(service.toString()),
|
||||
null, null, "${Collections.SUPPORTS_VEVENT} DESC,${Collections.DISPLAY_NAME}").use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val values = ContentValues(cursor.columnCount)
|
||||
DatabaseUtils.cursorRowToContentValues(cursor, values)
|
||||
collections.add(CollectionInfo(values))
|
||||
}
|
||||
}
|
||||
|
||||
// Webcal: check whether calendar is already subscribed by ICSdroid
|
||||
// (or any other app that stores the URL in Calendars.NAME)
|
||||
val webcalCollections = collections.filter { it.type == CollectionInfo.Type.WEBCAL }
|
||||
if (webcalCollections.isNotEmpty() && ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) == PackageManager.PERMISSION_GRANTED)
|
||||
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider ->
|
||||
try {
|
||||
for (info in webcalCollections) {
|
||||
provider.query(CalendarContract.Calendars.CONTENT_URI, null,
|
||||
"${CalendarContract.Calendars.NAME}=?", arrayOf(info.source), null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
info.selected = true
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
provider.release()
|
||||
}
|
||||
}
|
||||
|
||||
return collections
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/* LIST ADAPTERS */
|
||||
|
||||
class AddressBookAdapter(
|
||||
context: Context
|
||||
): ArrayAdapter<CollectionInfo>(context, R.layout.account_carddav_item) {
|
||||
override fun getView(position: Int, v: View?, parent: ViewGroup?): View {
|
||||
val v = v ?: LayoutInflater.from(context).inflate(R.layout.account_carddav_item, parent, false)
|
||||
val info = getItem(position)
|
||||
|
||||
val checked: CheckBox = v.findViewById(R.id.checked)
|
||||
checked.isChecked = info.selected
|
||||
|
||||
var tv: TextView = v.findViewById(R.id.title)
|
||||
tv.text = if (!info.displayName.isNullOrBlank()) info.displayName else info.url
|
||||
|
||||
tv = v.findViewById(R.id.description)
|
||||
if (info.description.isNullOrBlank())
|
||||
tv.visibility = View.GONE
|
||||
else {
|
||||
tv.visibility = View.VISIBLE
|
||||
tv.text = info.description
|
||||
}
|
||||
|
||||
v.findViewById<ImageView>(R.id.read_only).visibility =
|
||||
if (info.readOnly || info.forceReadOnly) View.VISIBLE else View.GONE
|
||||
|
||||
v.findViewById<ImageView>(R.id.action_overflow).setOnClickListener({ view ->
|
||||
(context as? AccountActivity)?.let {
|
||||
it.onActionOverflowListener(view, info)
|
||||
}
|
||||
})
|
||||
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
class CalendarAdapter(
|
||||
context: Context
|
||||
): ArrayAdapter<CollectionInfo>(context, R.layout.account_caldav_item) {
|
||||
override fun getView(position: Int, v: View?, parent: ViewGroup?): View {
|
||||
val v = v ?: LayoutInflater.from(context).inflate(R.layout.account_caldav_item, parent, false)
|
||||
val info = getItem(position)
|
||||
|
||||
val enabled = info.selected || info.supportsVEVENT || info.supportsVTODO
|
||||
v.isEnabled = enabled
|
||||
v.checked.isEnabled = enabled
|
||||
|
||||
val checked: CheckBox = v.findViewById(R.id.checked)
|
||||
checked.isChecked = info.selected
|
||||
|
||||
val vColor: View = v.findViewById(R.id.color)
|
||||
vColor.visibility = info.color?.let {
|
||||
vColor.setBackgroundColor(it)
|
||||
View.VISIBLE
|
||||
} ?: View.INVISIBLE
|
||||
|
||||
var tv: TextView = v.findViewById(R.id.title)
|
||||
tv.text = if (!info.displayName.isNullOrBlank()) info.displayName else info.url
|
||||
|
||||
tv = v.findViewById(R.id.description)
|
||||
if (info.description.isNullOrBlank())
|
||||
tv.visibility = View.GONE
|
||||
else {
|
||||
tv.visibility = View.VISIBLE
|
||||
tv.text = info.description
|
||||
}
|
||||
|
||||
v.findViewById<ImageView>(R.id.read_only).visibility =
|
||||
if (info.readOnly || info.forceReadOnly) View.VISIBLE else View.GONE
|
||||
|
||||
v.findViewById<ImageView>(R.id.events).visibility =
|
||||
if (info.supportsVEVENT) View.VISIBLE else View.GONE
|
||||
|
||||
v.findViewById<ImageView>(R.id.tasks).visibility =
|
||||
if (info.supportsVTODO) View.VISIBLE else View.GONE
|
||||
|
||||
val overflow = v.findViewById<ImageView>(R.id.action_overflow)
|
||||
if (info.type == CollectionInfo.Type.WEBCAL)
|
||||
overflow.visibility = View.GONE
|
||||
else
|
||||
overflow.setOnClickListener({ view ->
|
||||
(context as? AccountActivity)?.let {
|
||||
it.onActionOverflowListener(view, info)
|
||||
}
|
||||
})
|
||||
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* DIALOG FRAGMENTS */
|
||||
|
||||
class RenameAccountFragment: DialogFragment() {
|
||||
|
||||
companion object {
|
||||
|
||||
val ARG_ACCOUNT = "account"
|
||||
|
||||
fun newInstance(account: Account): RenameAccountFragment {
|
||||
val fragment = RenameAccountFragment()
|
||||
val args = Bundle(1)
|
||||
args.putParcelable(ARG_ACCOUNT, account)
|
||||
fragment.arguments = args
|
||||
return fragment
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val oldAccount: Account = arguments!!.getParcelable(ARG_ACCOUNT)
|
||||
|
||||
val editText = EditText(activity)
|
||||
editText.setText(oldAccount.name)
|
||||
|
||||
return AlertDialog.Builder(activity!!)
|
||||
.setTitle(R.string.account_rename)
|
||||
.setMessage(R.string.account_rename_new_name)
|
||||
.setView(editText)
|
||||
.setPositiveButton(R.string.account_rename_rename, DialogInterface.OnClickListener { _, _ ->
|
||||
val newName = editText.text.toString()
|
||||
|
||||
if (newName == oldAccount.name)
|
||||
return@OnClickListener
|
||||
|
||||
val accountManager = AccountManager.get(activity)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
|
||||
accountManager.renameAccount(oldAccount, newName, { _ ->
|
||||
Logger.log.info("Updating account name references")
|
||||
|
||||
// cancel maybe running synchronization
|
||||
ContentResolver.cancelSync(oldAccount, null)
|
||||
for (addrBookAccount in accountManager.getAccountsByType(getString(R.string.account_type_address_book)))
|
||||
ContentResolver.cancelSync(addrBookAccount, null)
|
||||
|
||||
// update account name references in database
|
||||
OpenHelper(activity!!).use { dbHelper ->
|
||||
ServiceDB.onRenameAccount(dbHelper.writableDatabase, oldAccount.name, newName)
|
||||
}
|
||||
|
||||
// update main account of address book accounts
|
||||
try {
|
||||
for (addrBookAccount in accountManager.getAccountsByType(getString(R.string.account_type_address_book))) {
|
||||
val provider = activity!!.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
|
||||
try {
|
||||
if (provider != null) {
|
||||
val addressBook = LocalAddressBook(activity!!, addrBookAccount, provider)
|
||||
if (oldAccount == addressBook.mainAccount)
|
||||
addressBook.mainAccount = Account(newName, oldAccount.type)
|
||||
}
|
||||
} finally {
|
||||
if (Build.VERSION.SDK_INT >= 24)
|
||||
provider?.close()
|
||||
else
|
||||
provider?.release()
|
||||
}
|
||||
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't update address book accounts", e)
|
||||
}
|
||||
|
||||
// calendar provider doesn't allow changing account_name of Events
|
||||
// (all events will have to be downloaded again)
|
||||
|
||||
// update account_name of local tasks
|
||||
try {
|
||||
LocalTaskList.onRenameAccount(activity!!.contentResolver, oldAccount.name, newName)
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't propagate new account name to tasks provider", e)
|
||||
}
|
||||
|
||||
// synchronize again
|
||||
requestSync(activity!!, Account(newName, oldAccount.type))
|
||||
}, null)
|
||||
activity!!.finish()
|
||||
})
|
||||
.setNegativeButton(android.R.string.cancel, { _, _ -> })
|
||||
.create()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* USER ACTIONS */
|
||||
|
||||
private fun deleteAccount() {
|
||||
val accountManager = AccountManager.get(this)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 22)
|
||||
accountManager.removeAccount(account, this, { future ->
|
||||
try {
|
||||
if (future.result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT))
|
||||
finish()
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't remove account", e)
|
||||
}
|
||||
}, null)
|
||||
else
|
||||
accountManager.removeAccount(account, { future ->
|
||||
try {
|
||||
if (future.result)
|
||||
finish()
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't remove account", e)
|
||||
}
|
||||
}, null)
|
||||
}
|
||||
|
||||
private fun requestSync() {
|
||||
requestSync(this, account)
|
||||
Snackbar.make(findViewById(R.id.parent), R.string.account_synchronizing_now, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -11,12 +11,16 @@ package at.bitfire.davdroid.ui
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.accounts.OnAccountsUpdateListener
|
||||
import android.app.ListFragment
|
||||
import android.app.LoaderManager
|
||||
import android.content.AsyncTaskLoader
|
||||
import android.app.Application
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Loader
|
||||
import android.content.IntentFilter
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
@@ -24,24 +28,41 @@ import android.view.ViewGroup
|
||||
import android.widget.AbsListView
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.fragment.app.ListFragment
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.account.AccountActivity
|
||||
import kotlinx.android.synthetic.main.account_list.*
|
||||
import kotlinx.android.synthetic.main.account_list_item.view.*
|
||||
|
||||
class AccountListFragment: ListFragment(), LoaderManager.LoaderCallbacks<Array<Account>> {
|
||||
class AccountListFragment: ListFragment() {
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
listAdapter = AccountListAdapter(activity)
|
||||
listAdapter = AccountListAdapter(requireActivity())
|
||||
|
||||
val model = ViewModelProvider(this).get(Model::class.java)
|
||||
model.accounts.observe(viewLifecycleOwner, Observer { accounts ->
|
||||
val adapter = listAdapter as AccountListAdapter
|
||||
adapter.clear()
|
||||
adapter.addAll(*accounts)
|
||||
})
|
||||
|
||||
model.networkAvailable.observe(viewLifecycleOwner, Observer { networkAvailable ->
|
||||
no_network_info.visibility = if (networkAvailable) View.GONE else View.VISIBLE
|
||||
})
|
||||
|
||||
return inflater.inflate(R.layout.account_list, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
loaderManager.initLoader(0, arguments, this)
|
||||
|
||||
listView.choiceMode = AbsListView.CHOICE_MODE_SINGLE
|
||||
listView.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ ->
|
||||
val account = listAdapter.getItem(position) as Account
|
||||
val account = listAdapter!!.getItem(position) as Account
|
||||
val intent = Intent(activity, AccountActivity::class.java)
|
||||
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
|
||||
startActivity(intent)
|
||||
@@ -49,66 +70,103 @@ class AccountListFragment: ListFragment(), LoaderManager.LoaderCallbacks<Array<A
|
||||
}
|
||||
|
||||
|
||||
// loader
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) =
|
||||
AccountLoader(activity)
|
||||
|
||||
override fun onLoadFinished(loader: Loader<Array<Account>>, accounts: Array<Account>) {
|
||||
val adapter = listAdapter as AccountListAdapter
|
||||
adapter.clear()
|
||||
adapter.addAll(*accounts)
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<Array<Account>>) {
|
||||
(listAdapter as AccountListAdapter).clear()
|
||||
}
|
||||
|
||||
class AccountLoader(
|
||||
context: Context
|
||||
): AsyncTaskLoader<Array<Account>>(context) {
|
||||
|
||||
private val accountManager = AccountManager.get(context)!!
|
||||
private var listener: OnAccountsUpdateListener? = null
|
||||
|
||||
override fun onStartLoading() {
|
||||
if (listener == null) {
|
||||
listener = OnAccountsUpdateListener { onContentChanged() }
|
||||
accountManager.addOnAccountsUpdatedListener(listener, null, false)
|
||||
}
|
||||
|
||||
forceLoad()
|
||||
}
|
||||
|
||||
override fun onReset() {
|
||||
listener?.let {
|
||||
try {
|
||||
accountManager.removeOnAccountsUpdatedListener(it)
|
||||
} catch(ignored: IllegalArgumentException) {}
|
||||
listener = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadInBackground(): Array<Account> =
|
||||
AccountManager.get(context).getAccountsByType(context.getString(R.string.account_type))
|
||||
|
||||
}
|
||||
|
||||
|
||||
// list adapter
|
||||
|
||||
class AccountListAdapter(
|
||||
context: Context
|
||||
): ArrayAdapter<Account>(context, R.layout.account_list_item) {
|
||||
|
||||
override fun getView(position: Int, v: View?, parent: ViewGroup?): View {
|
||||
val account = getItem(position)
|
||||
override fun getView(position: Int, _v: View?, parent: ViewGroup): View {
|
||||
val account = getItem(position)!!
|
||||
|
||||
val v = v ?: LayoutInflater.from(context).inflate(R.layout.account_list_item, parent, false)
|
||||
val v = _v ?: LayoutInflater.from(context).inflate(R.layout.account_list_item, parent, false)
|
||||
v.account_name.text = account.name
|
||||
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Model(
|
||||
application: Application
|
||||
): AndroidViewModel(application), OnAccountsUpdateListener {
|
||||
|
||||
val accounts = MutableLiveData<Array<out Account>>()
|
||||
|
||||
val networkAvailable = MutableLiveData<Boolean>()
|
||||
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
||||
private var networkReceiver: BroadcastReceiver? = null
|
||||
|
||||
private val accountManager = AccountManager.get(getApplication())!!
|
||||
private val connectivityManager = application.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
init {
|
||||
accountManager.addOnAccountsUpdatedListener(this, null, true)
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { // API level <26
|
||||
networkReceiver = object: BroadcastReceiver() {
|
||||
init {
|
||||
update()
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context?, intent: Intent?) = update()
|
||||
|
||||
private fun update() {
|
||||
networkAvailable.postValue(connectivityManager.allNetworkInfo.any { it.isConnected })
|
||||
}
|
||||
}
|
||||
application.registerReceiver(networkReceiver, IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION))
|
||||
|
||||
} else { // API level >= 26
|
||||
networkAvailable.postValue(false)
|
||||
|
||||
// check for working (e.g. WiFi after captive portal login) Internet connection
|
||||
val networkRequest = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
.build()
|
||||
val callback = object: ConnectivityManager.NetworkCallback() {
|
||||
val availableNetworks = hashSetOf<Network>()
|
||||
|
||||
override fun onAvailable(network: Network) {
|
||||
availableNetworks += network
|
||||
update()
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
availableNetworks -= network
|
||||
update()
|
||||
}
|
||||
|
||||
private fun update() {
|
||||
networkAvailable.postValue(availableNetworks.isNotEmpty())
|
||||
}
|
||||
}
|
||||
connectivityManager.registerNetworkCallback(networkRequest, callback)
|
||||
networkCallback = callback
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
accountManager.removeOnAccountsUpdatedListener(this)
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M)
|
||||
networkReceiver?.let {
|
||||
getApplication<Application>().unregisterReceiver(it)
|
||||
}
|
||||
|
||||
else
|
||||
networkCallback?.let {
|
||||
connectivityManager.unregisterNetworkCallback(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccountsUpdated(newAccounts: Array<out Account>) {
|
||||
val context = getApplication<Application>()
|
||||
accounts.postValue(
|
||||
AccountManager.get(context).getAccountsByType(context.getString(R.string.account_type))
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,375 +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.Manifest
|
||||
import android.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SyncStatusObserver
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.CalendarContract
|
||||
import android.security.KeyChain
|
||||
import android.support.v4.app.ActivityCompat
|
||||
import android.support.v4.app.DialogFragment
|
||||
import android.support.v4.app.LoaderManager
|
||||
import android.support.v4.app.NavUtils
|
||||
import android.support.v4.content.ContextCompat
|
||||
import android.support.v4.content.Loader
|
||||
import android.support.v7.app.AlertDialog
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.support.v7.preference.*
|
||||
import android.view.MenuItem
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.model.Credentials
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
|
||||
class AccountSettingsActivity: AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_ACCOUNT = "account"
|
||||
}
|
||||
|
||||
private lateinit var account: Account
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
account = intent.getParcelableExtra(EXTRA_ACCOUNT)
|
||||
title = getString(R.string.settings_title, account.name)
|
||||
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
if (savedInstanceState == null)
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(android.R.id.content, DialogFragment.instantiate(this, AccountSettingsFragment::class.java.name, intent.extras))
|
||||
.commit()
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem) =
|
||||
if (item.itemId == android.R.id.home) {
|
||||
val intent = Intent(this, AccountActivity::class.java)
|
||||
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
|
||||
NavUtils.navigateUpTo(this, intent)
|
||||
true
|
||||
} else
|
||||
false
|
||||
|
||||
|
||||
class AccountSettingsFragment: PreferenceFragmentCompat(), LoaderManager.LoaderCallbacks<Pair<ISettings, AccountSettings>> {
|
||||
lateinit var account: Account
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
account = arguments!!.getParcelable(EXTRA_ACCOUNT)
|
||||
loaderManager.initLoader(0, arguments, this)
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.settings_account)
|
||||
}
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) =
|
||||
AccountSettingsLoader(requireActivity(), args!!.getParcelable(EXTRA_ACCOUNT))
|
||||
|
||||
override fun onLoadFinished(loader: Loader<Pair<ISettings, AccountSettings>>, result: Pair<ISettings, AccountSettings>?) {
|
||||
val (settings, accountSettings) = result ?: return
|
||||
|
||||
// preference group: authentication
|
||||
val prefUserName = findPreference("username") as EditTextPreference
|
||||
val prefPassword = findPreference("password") as EditTextPreference
|
||||
val prefCertAlias = findPreference("certificate_alias") as Preference
|
||||
|
||||
val credentials = accountSettings.credentials()
|
||||
when (credentials.type) {
|
||||
Credentials.Type.UsernamePassword -> {
|
||||
prefUserName.isVisible = true
|
||||
prefUserName.summary = credentials.userName
|
||||
prefUserName.text = credentials.userName
|
||||
prefUserName.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
accountSettings.credentials(Credentials(newValue as String, credentials.password))
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
|
||||
prefPassword.isVisible = true
|
||||
prefPassword.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
accountSettings.credentials(Credentials(credentials.userName, newValue as String))
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
|
||||
prefCertAlias.isVisible = false
|
||||
}
|
||||
Credentials.Type.ClientCertificate -> {
|
||||
prefUserName.isVisible = false
|
||||
prefPassword.isVisible = false
|
||||
|
||||
prefCertAlias.isVisible = true
|
||||
prefCertAlias.summary = credentials.certificateAlias
|
||||
prefCertAlias.setOnPreferenceClickListener {
|
||||
KeyChain.choosePrivateKeyAlias(activity, { alias ->
|
||||
accountSettings.credentials(Credentials(certificateAlias = alias))
|
||||
Handler(Looper.getMainLooper()).post({
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
})
|
||||
}, null, null, null, -1, credentials.certificateAlias)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// preference group: sync
|
||||
// those are null if the respective sync type is not available for this account:
|
||||
val syncIntervalContacts = accountSettings.getSyncInterval(getString(R.string.address_books_authority))
|
||||
val syncIntervalCalendars = accountSettings.getSyncInterval(CalendarContract.AUTHORITY)
|
||||
val syncIntervalTasks = accountSettings.getSyncInterval(TaskProvider.ProviderName.OpenTasks.authority)
|
||||
|
||||
(findPreference("sync_interval_contacts") as ListPreference).let {
|
||||
if (syncIntervalContacts != null) {
|
||||
it.isVisible = true
|
||||
it.value = syncIntervalContacts.toString()
|
||||
if (syncIntervalContacts == AccountSettings.SYNC_INTERVAL_MANUALLY)
|
||||
it.setSummary(R.string.settings_sync_summary_manually)
|
||||
else
|
||||
it.summary = getString(R.string.settings_sync_summary_periodically, syncIntervalContacts / 60)
|
||||
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
accountSettings.setSyncInterval(getString(R.string.address_books_authority), (newValue as String).toLong())
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
} else
|
||||
it.isVisible = false
|
||||
}
|
||||
|
||||
(findPreference("sync_interval_calendars") as ListPreference).let {
|
||||
if (syncIntervalCalendars != null) {
|
||||
it.isVisible = true
|
||||
it.value = syncIntervalCalendars.toString()
|
||||
if (syncIntervalCalendars == AccountSettings.SYNC_INTERVAL_MANUALLY)
|
||||
it.setSummary(R.string.settings_sync_summary_manually)
|
||||
else
|
||||
it.summary = getString(R.string.settings_sync_summary_periodically, syncIntervalCalendars / 60)
|
||||
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
accountSettings.setSyncInterval(CalendarContract.AUTHORITY, (newValue as String).toLong())
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
} else
|
||||
it.isVisible = false
|
||||
}
|
||||
|
||||
(findPreference("sync_interval_tasks") as ListPreference).let {
|
||||
if (syncIntervalTasks != null) {
|
||||
it.isVisible = true
|
||||
it.value = syncIntervalTasks.toString()
|
||||
if (syncIntervalTasks == AccountSettings.SYNC_INTERVAL_MANUALLY)
|
||||
it.setSummary(R.string.settings_sync_summary_manually)
|
||||
else
|
||||
it.summary = getString(R.string.settings_sync_summary_periodically, syncIntervalTasks / 60)
|
||||
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
accountSettings.setSyncInterval(TaskProvider.ProviderName.OpenTasks.authority, (newValue as String).toLong())
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
} else
|
||||
it.isVisible = false
|
||||
}
|
||||
|
||||
val prefWifiOnly = findPreference("sync_wifi_only") as SwitchPreferenceCompat
|
||||
prefWifiOnly.isEnabled = !settings.has(AccountSettings.KEY_WIFI_ONLY)
|
||||
prefWifiOnly.isChecked = accountSettings.getSyncWifiOnly()
|
||||
prefWifiOnly.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, wifiOnly ->
|
||||
accountSettings.setSyncWiFiOnly(wifiOnly as Boolean)
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
|
||||
val prefWifiOnlySSIDs = findPreference("sync_wifi_only_ssids") as EditTextPreference
|
||||
val onlySSIDs = accountSettings.getSyncWifiOnlySSIDs()?.joinToString(", ")
|
||||
prefWifiOnlySSIDs.text = onlySSIDs
|
||||
if (onlySSIDs != null)
|
||||
prefWifiOnlySSIDs.summary = getString(R.string.settings_sync_wifi_only_ssids_on, onlySSIDs)
|
||||
else
|
||||
prefWifiOnlySSIDs.setSummary(R.string.settings_sync_wifi_only_ssids_off)
|
||||
prefWifiOnlySSIDs.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
accountSettings.setSyncWifiOnlySSIDs((newValue as String).split(',').mapNotNull { StringUtils.trimToNull(it) }.distinct())
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
|
||||
// getting the WiFi name requires location permission (and active location services) since Android 8.1
|
||||
// see https://issuetracker.google.com/issues/70633700
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 &&
|
||||
accountSettings.getSyncWifiOnly() && onlySSIDs != null &&
|
||||
ContextCompat.checkSelfPermission(requireActivity(), Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED)
|
||||
ActivityCompat.requestPermissions(requireActivity(), arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION), 0)
|
||||
|
||||
// preference group: CardDAV
|
||||
(findPreference("contact_group_method") as ListPreference).let {
|
||||
if (syncIntervalContacts != null) {
|
||||
it.isVisible = true
|
||||
it.value = accountSettings.getGroupMethod().name
|
||||
it.summary = it.entry
|
||||
if (settings.has(AccountSettings.KEY_CONTACT_GROUP_METHOD))
|
||||
it.isEnabled = false
|
||||
else {
|
||||
it.isEnabled = true
|
||||
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, groupMethod ->
|
||||
AlertDialog.Builder(requireActivity())
|
||||
.setIcon(R.drawable.ic_error_dark)
|
||||
.setTitle(R.string.settings_contact_group_method_change)
|
||||
.setMessage(R.string.settings_contact_group_method_change_reload_contacts)
|
||||
.setPositiveButton(android.R.string.ok, { _, _ ->
|
||||
// change group method
|
||||
accountSettings.setGroupMethod(GroupMethod.valueOf(groupMethod as String))
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
|
||||
// reload all contacts
|
||||
val args = Bundle(1)
|
||||
args.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true)
|
||||
ContentResolver.requestSync(account, getString(R.string.address_books_authority), args)
|
||||
})
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
false
|
||||
}
|
||||
}
|
||||
} else
|
||||
it.isVisible = false
|
||||
}
|
||||
|
||||
// preference group: CalDAV
|
||||
(findPreference("time_range_past_days") as EditTextPreference).let {
|
||||
if (syncIntervalCalendars != null) {
|
||||
it.isVisible = true
|
||||
val pastDays = accountSettings.getTimeRangePastDays()
|
||||
if (pastDays != null) {
|
||||
it.text = pastDays.toString()
|
||||
it.summary = resources.getQuantityString(R.plurals.settings_sync_time_range_past_days, pastDays, pastDays)
|
||||
} else {
|
||||
it.text = null
|
||||
it.setSummary(R.string.settings_sync_time_range_past_none)
|
||||
}
|
||||
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
val days = try {
|
||||
(newValue as String).toInt()
|
||||
} catch(e: NumberFormatException) {
|
||||
-1
|
||||
}
|
||||
accountSettings.setTimeRangePastDays(if (days < 0) null else days)
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
} else
|
||||
it.isVisible = false
|
||||
}
|
||||
|
||||
(findPreference("manage_calendar_colors") as SwitchPreferenceCompat).let {
|
||||
if (syncIntervalCalendars != null || syncIntervalTasks != null) {
|
||||
it.isVisible = true
|
||||
it.isEnabled = !settings.has(AccountSettings.KEY_MANAGE_CALENDAR_COLORS)
|
||||
it.isChecked = accountSettings.getManageCalendarColors()
|
||||
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
accountSettings.setManageCalendarColors(newValue as Boolean)
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
} else
|
||||
it.isVisible = false
|
||||
}
|
||||
|
||||
(findPreference("event_colors") as SwitchPreferenceCompat).let {
|
||||
if (syncIntervalCalendars != null) {
|
||||
it.isVisible = true
|
||||
it.isEnabled = !settings.has(AccountSettings.KEY_EVENT_COLORS)
|
||||
it.isChecked = accountSettings.getEventColors()
|
||||
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
if (newValue as Boolean) {
|
||||
accountSettings.setEventColors(true)
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
} else
|
||||
AlertDialog.Builder(requireActivity())
|
||||
.setIcon(R.drawable.ic_error_dark)
|
||||
.setTitle(R.string.settings_event_colors)
|
||||
.setMessage(R.string.settings_event_colors_off_confirm)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(android.R.string.ok, { _, _ ->
|
||||
accountSettings.setEventColors(false)
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
})
|
||||
.show()
|
||||
false
|
||||
}
|
||||
} else
|
||||
it.isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<Pair<ISettings, AccountSettings>>) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class AccountSettingsLoader(
|
||||
context: Context,
|
||||
val account: Account
|
||||
): SettingsLoader<Pair<ISettings, AccountSettings>?>(context), SyncStatusObserver {
|
||||
|
||||
private var listenerHandle: Any? = null
|
||||
|
||||
override fun onStartLoading() {
|
||||
super.onStartLoading()
|
||||
|
||||
if (listenerHandle == null)
|
||||
listenerHandle = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this)
|
||||
}
|
||||
|
||||
override fun onReset() {
|
||||
super.onReset()
|
||||
|
||||
listenerHandle?.let {
|
||||
ContentResolver.removeStatusChangeListener(it)
|
||||
listenerHandle = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadInBackground(): Pair<ISettings, AccountSettings>? {
|
||||
settings?.let { settings ->
|
||||
try {
|
||||
return Pair(
|
||||
settings,
|
||||
AccountSettings(context, settings, account)
|
||||
)
|
||||
} catch (e: InvalidAccountException) {
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override fun onStatusChanged(which: Int) {
|
||||
onContentChanged()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,87 +9,72 @@
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Activity
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SyncStatusObserver
|
||||
import android.os.Bundle
|
||||
import android.support.design.widget.NavigationView
|
||||
import android.support.design.widget.Snackbar
|
||||
import android.support.v4.app.LoaderManager
|
||||
import android.support.v4.content.Loader
|
||||
import android.support.v4.view.GravityCompat
|
||||
import android.support.v7.app.ActionBarDrawerToggle
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import at.bitfire.davdroid.App
|
||||
import androidx.appcompat.app.ActionBarDrawerToggle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.GravityCompat
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.ui.intro.IntroActivity
|
||||
import at.bitfire.davdroid.ui.setup.LoginActivity
|
||||
import com.google.android.material.navigation.NavigationView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.android.synthetic.main.accounts_content.*
|
||||
import kotlinx.android.synthetic.main.activity_accounts.*
|
||||
import kotlinx.android.synthetic.main.activity_accounts.view.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.*
|
||||
|
||||
class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener, LoaderManager.LoaderCallbacks<AccountsActivity.Settings>, SyncStatusObserver {
|
||||
class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener, SyncStatusObserver {
|
||||
|
||||
companion object {
|
||||
val accountsDrawerHandler = DefaultAccountsDrawerHandler()
|
||||
|
||||
private const val fragTagStartup = "startup"
|
||||
const val REQUEST_INTRO = 0
|
||||
}
|
||||
|
||||
private lateinit var settings: Settings
|
||||
|
||||
private var syncStatusSnackbar: Snackbar? = null
|
||||
private var syncStatusObserver: Any? = null
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_accounts)
|
||||
settings = Settings.getInstance(this)
|
||||
|
||||
if (savedInstanceState == null)
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
// use a separate thread to check whether IntroActivity should be shown
|
||||
if (IntroActivity.shouldShowIntroActivity(this@AccountsActivity)) {
|
||||
val intro = Intent(this@AccountsActivity, IntroActivity::class.java)
|
||||
startActivityForResult(intro, REQUEST_INTRO)
|
||||
}
|
||||
}
|
||||
|
||||
setContentView(R.layout.activity_accounts)
|
||||
setSupportActionBar(toolbar)
|
||||
|
||||
fab.setOnClickListener({
|
||||
fab.setOnClickListener {
|
||||
startActivity(Intent(this, LoginActivity::class.java))
|
||||
})
|
||||
}
|
||||
fab.show()
|
||||
|
||||
accountsDrawerHandler.initMenu(this, drawer_layout.nav_view.menu)
|
||||
val toggle = ActionBarDrawerToggle(
|
||||
this, drawer_layout, toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close)
|
||||
drawer_layout.setDrawerListener(toggle)
|
||||
drawer_layout.addDrawerListener(toggle)
|
||||
toggle.syncState()
|
||||
|
||||
nav_view.setNavigationItemSelectedListener(this)
|
||||
nav_view.itemIconTintList = null
|
||||
|
||||
/* When the DAVdroid main activity is started, start a Settings service that stays in memory
|
||||
for better performance. The service stops itself when memory is trimmed. */
|
||||
val settingsIntent = Intent(this, Settings::class.java)
|
||||
startService(settingsIntent)
|
||||
|
||||
val args = Bundle(1)
|
||||
supportLoaderManager.initLoader(0, args, this)
|
||||
}
|
||||
|
||||
override fun onCreateLoader(code: Int, args: Bundle?) =
|
||||
SettingsLoader(this)
|
||||
|
||||
override fun onLoadFinished(loader: Loader<Settings>, result: Settings?) {
|
||||
val result = result ?: return
|
||||
|
||||
if (supportFragmentManager.findFragmentByTag(fragTagStartup) == null) {
|
||||
val ft = supportFragmentManager.beginTransaction()
|
||||
StartupDialogFragment.getStartupDialogs(this, result.settings).forEach { ft.add(it, fragTagStartup) }
|
||||
ft.commit()
|
||||
}
|
||||
|
||||
nav_view?.menu?.let {
|
||||
accountsDrawerHandler.onSettingsChanged(result.settings, it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<Settings>) {
|
||||
nav_view?.menu?.let {
|
||||
accountsDrawerHandler.onSettingsChanged(null, it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@@ -117,15 +102,22 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
|
||||
if (!ContentResolver.getMasterSyncAutomatically()) {
|
||||
val snackbar = Snackbar
|
||||
.make(findViewById(R.id.coordinator), R.string.accounts_global_sync_disabled, Snackbar.LENGTH_INDEFINITE)
|
||||
.setAction(R.string.accounts_global_sync_enable, {
|
||||
ContentResolver.setMasterSyncAutomatically(true)
|
||||
})
|
||||
.setAction(R.string.accounts_global_sync_enable) {
|
||||
ContentResolver.setMasterSyncAutomatically(true)
|
||||
}
|
||||
syncStatusSnackbar = snackbar
|
||||
snackbar.show()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == REQUEST_INTRO && resultCode == Activity.RESULT_CANCELED)
|
||||
finish()
|
||||
else
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (drawer_layout.isDrawerOpen(GravityCompat.START))
|
||||
drawer_layout.closeDrawer(GravityCompat.START)
|
||||
@@ -139,27 +131,4 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
|
||||
return processed
|
||||
}
|
||||
|
||||
|
||||
class Settings(
|
||||
val settings: ISettings
|
||||
)
|
||||
|
||||
class SettingsLoader(
|
||||
context: Context
|
||||
): at.bitfire.davdroid.ui.SettingsLoader<Settings>(context) {
|
||||
|
||||
override fun loadInBackground(): Settings? {
|
||||
settings?.let {
|
||||
val accountManager = AccountManager.get(context)
|
||||
val accounts = accountManager.getAccountsByType(context.getString(R.string.account_type))
|
||||
|
||||
return Settings(
|
||||
it
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,28 +8,22 @@
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.os.Process
|
||||
import android.support.design.widget.Snackbar
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.support.v7.preference.EditTextPreference
|
||||
import android.support.v7.preference.Preference
|
||||
import android.support.v7.preference.PreferenceFragmentCompat
|
||||
import android.support.v7.preference.SwitchPreferenceCompat
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import at.bitfire.cert4android.CustomCertManager
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import at.bitfire.davdroid.settings.ISettingsObserver
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.ui.intro.BatteryOptimizationsFragment
|
||||
import at.bitfire.davdroid.ui.intro.OpenSourceFragment
|
||||
import at.bitfire.davdroid.ui.intro.OpenTasksFragment
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
|
||||
@@ -52,144 +46,113 @@ class AppSettingsActivity: AppCompatActivity() {
|
||||
}
|
||||
|
||||
|
||||
class SettingsFragment: PreferenceFragmentCompat() {
|
||||
|
||||
val observer = object: ISettingsObserver.Stub() {
|
||||
override fun onSettingsChanged() {
|
||||
loadSettings()
|
||||
}
|
||||
}
|
||||
|
||||
var settings: ISettings? = null
|
||||
var settingsSvc: ServiceConnection? = object: ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName, binder: IBinder) {
|
||||
settings = ISettings.Stub.asInterface(binder)
|
||||
settings?.registerObserver(observer)
|
||||
loadSettings()
|
||||
}
|
||||
override fun onServiceDisconnected(name: ComponentName) {
|
||||
settings?.unregisterObserver(observer)
|
||||
settings = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (!activity!!.bindService(Intent(activity, Settings::class.java), settingsSvc, Context.BIND_AUTO_CREATE))
|
||||
settingsSvc = null
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
settingsSvc?.let { activity!!.unbindService(it) }
|
||||
}
|
||||
class SettingsFragment: PreferenceFragmentCompat(), Settings.OnChangeListener {
|
||||
|
||||
override fun onCreatePreferences(bundle: Bundle?, s: String?) {
|
||||
addPreferencesFromResource(R.xml.settings_app)
|
||||
loadSettings()
|
||||
|
||||
// UI settings
|
||||
val prefResetHints = findPreference("reset_hints")
|
||||
prefResetHints.onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
findPreference<Preference>("notification_settings")!!.apply {
|
||||
if (Build.VERSION.SDK_INT >= 26)
|
||||
onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
startActivity(Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID)
|
||||
})
|
||||
false
|
||||
}
|
||||
else
|
||||
isVisible = false
|
||||
}
|
||||
findPreference<Preference>("reset_hints")!!.onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
resetHints()
|
||||
false
|
||||
}
|
||||
|
||||
// security settings
|
||||
val prefDistrustSystemCerts = findPreference(App.DISTRUST_SYSTEM_CERTIFICATES)
|
||||
prefDistrustSystemCerts.isVisible = BuildConfig.customCerts
|
||||
prefDistrustSystemCerts.isEnabled = true
|
||||
|
||||
val prefResetCertificates = findPreference("reset_certificates")
|
||||
prefResetCertificates.isVisible = BuildConfig.customCerts
|
||||
prefResetCertificates.isEnabled = true
|
||||
prefResetCertificates.onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
resetCertificates()
|
||||
false
|
||||
}
|
||||
|
||||
arguments?.getString(EXTRA_SCROLL_TO)?.let { scrollToPreference(it) }
|
||||
}
|
||||
|
||||
private fun loadSettings() {
|
||||
val settings = requireNotNull(settings)
|
||||
|
||||
// connection settings
|
||||
val prefOverrideProxy = findPreference(App.OVERRIDE_PROXY) as SwitchPreferenceCompat
|
||||
prefOverrideProxy.isChecked = settings.getBoolean(App.OVERRIDE_PROXY, false)
|
||||
prefOverrideProxy.isEnabled = settings.isWritable(App.OVERRIDE_PROXY)
|
||||
|
||||
val prefProxyHost = findPreference(App.OVERRIDE_PROXY_HOST) as EditTextPreference
|
||||
prefProxyHost.isEnabled = settings.isWritable(App.OVERRIDE_PROXY_HOST)
|
||||
val proxyHost = settings.getString(App.OVERRIDE_PROXY_HOST, App.OVERRIDE_PROXY_HOST_DEFAULT)
|
||||
prefProxyHost.text = proxyHost
|
||||
prefProxyHost.summary = proxyHost
|
||||
prefProxyHost.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
val host = newValue as String
|
||||
try {
|
||||
URI(null, host, null, null)
|
||||
settings.putString(App.OVERRIDE_PROXY_HOST, host)
|
||||
prefProxyHost.summary = host
|
||||
true
|
||||
} catch(e: URISyntaxException) {
|
||||
Snackbar.make(view!!, e.localizedMessage, Snackbar.LENGTH_LONG).show()
|
||||
findPreference<Preference>("reset_certificates")!!.apply {
|
||||
onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
resetCertificates()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
val prefProxyPort = findPreference(App.OVERRIDE_PROXY_PORT) as EditTextPreference
|
||||
prefProxyHost.isEnabled = settings.isWritable(App.OVERRIDE_PROXY_PORT)
|
||||
val proxyPort = settings.getInt(App.OVERRIDE_PROXY_PORT, App.OVERRIDE_PROXY_PORT_DEFAULT)
|
||||
prefProxyPort.text = proxyPort.toString()
|
||||
prefProxyPort.summary = proxyPort.toString()
|
||||
prefProxyPort.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
try {
|
||||
val port = Integer.parseInt(newValue as String)
|
||||
if (port in 1..65535) {
|
||||
settings.putInt(App.OVERRIDE_PROXY_PORT, port)
|
||||
prefProxyPort.text = port.toString()
|
||||
prefProxyPort.summary = port.toString()
|
||||
arguments?.getString(EXTRA_SCROLL_TO)?.let { key ->
|
||||
scrollToPreference(key)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadSettings() {
|
||||
val settings = Settings.getInstance(requireActivity())
|
||||
|
||||
// connection settings
|
||||
findPreference<SwitchPreferenceCompat>(Settings.OVERRIDE_PROXY)!!.apply {
|
||||
isChecked = settings.getBoolean(Settings.OVERRIDE_PROXY) ?: Settings.OVERRIDE_PROXY_DEFAULT
|
||||
isEnabled = settings.isWritable(Settings.OVERRIDE_PROXY)
|
||||
}
|
||||
|
||||
findPreference<EditTextPreference>(Settings.OVERRIDE_PROXY_HOST)!!.apply {
|
||||
isEnabled = settings.isWritable(Settings.OVERRIDE_PROXY_HOST)
|
||||
val proxyHost = settings.getString(Settings.OVERRIDE_PROXY_HOST) ?: Settings.OVERRIDE_PROXY_HOST_DEFAULT
|
||||
text = proxyHost
|
||||
summary = proxyHost
|
||||
onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
val host = newValue as String
|
||||
try {
|
||||
URI(null, host, null, null)
|
||||
settings.putString(Settings.OVERRIDE_PROXY_HOST, host)
|
||||
summary = host
|
||||
true
|
||||
} else
|
||||
} catch(e: URISyntaxException) {
|
||||
Snackbar.make(requireView(), e.localizedMessage, Snackbar.LENGTH_LONG).show()
|
||||
false
|
||||
} catch(e: NumberFormatException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findPreference<EditTextPreference>(Settings.OVERRIDE_PROXY_PORT)!!.apply {
|
||||
isEnabled = settings.isWritable(Settings.OVERRIDE_PROXY_PORT)
|
||||
val proxyPort = settings.getInt(Settings.OVERRIDE_PROXY_PORT) ?: Settings.OVERRIDE_PROXY_PORT_DEFAULT
|
||||
text = proxyPort.toString()
|
||||
summary = proxyPort.toString()
|
||||
onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
try {
|
||||
val port = Integer.parseInt(newValue as String)
|
||||
if (port in 1..65535) {
|
||||
settings.putInt(Settings.OVERRIDE_PROXY_PORT, port)
|
||||
text = port.toString()
|
||||
summary = port.toString()
|
||||
true
|
||||
} else
|
||||
false
|
||||
} catch(e: NumberFormatException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// security settings
|
||||
val prefDistrustSystemCerts = findPreference(App.DISTRUST_SYSTEM_CERTIFICATES) as SwitchPreferenceCompat
|
||||
prefDistrustSystemCerts.isChecked = settings.getBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, false)
|
||||
|
||||
// debugging settings
|
||||
val prefLogToExternalStorage = findPreference(Logger.LOG_TO_EXTERNAL_STORAGE) as SwitchPreferenceCompat
|
||||
prefLogToExternalStorage.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
val context = activity!!
|
||||
Logger.initialize(context)
|
||||
|
||||
// kill a potential :sync process, so that the new logger settings will be used
|
||||
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
am.runningAppProcesses.forEach {
|
||||
if (it.pid != Process.myPid()) {
|
||||
Logger.log.info("Killing ${it.processName} process, pid = ${it.pid}")
|
||||
Process.killProcess(it.pid)
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
findPreference<SwitchPreferenceCompat>(Settings.DISTRUST_SYSTEM_CERTIFICATES)!!
|
||||
.isChecked = settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES) ?: Settings.DISTRUST_SYSTEM_CERTIFICATES_DEFAULT
|
||||
}
|
||||
|
||||
override fun onSettingsChanged() {
|
||||
loadSettings()
|
||||
}
|
||||
|
||||
|
||||
private fun resetHints() {
|
||||
settings?.remove(StartupDialogFragment.HINT_AUTOSTART_PERMISSIONS)
|
||||
settings?.remove(StartupDialogFragment.HINT_BATTERY_OPTIMIZATIONS)
|
||||
settings?.remove(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED)
|
||||
settings?.remove(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED)
|
||||
Snackbar.make(view!!, R.string.app_settings_reset_hints_success, Snackbar.LENGTH_LONG).show()
|
||||
val settings = Settings.getInstance(requireActivity())
|
||||
settings.remove(BatteryOptimizationsFragment.Model.HINT_BATTERY_OPTIMIZATIONS)
|
||||
settings.remove(BatteryOptimizationsFragment.Model.HINT_AUTOSTART_PERMISSION)
|
||||
settings.remove(OpenTasksFragment.Model.HINT_OPENTASKS_NOT_INSTALLED)
|
||||
settings.remove(OpenSourceFragment.Model.SETTING_NEXT_DONATION_POPUP)
|
||||
Snackbar.make(requireView(), R.string.app_settings_reset_hints_success, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
private fun resetCertificates() {
|
||||
if (CustomCertManager.resetCertificates(activity!!))
|
||||
Snackbar.make(view!!, getString(R.string.app_settings_reset_certificates_success), Snackbar.LENGTH_LONG).show()
|
||||
if (CustomCertManager.resetCertificates(requireActivity()))
|
||||
Snackbar.make(requireView(), getString(R.string.app_settings_reset_certificates_success), Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,43 +9,50 @@
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.LoaderManager
|
||||
import android.content.AsyncTaskLoader
|
||||
import android.content.Context
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.content.Loader
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.NavUtils
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.NavUtils
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import kotlinx.android.synthetic.main.activity_create_address_book.*
|
||||
import okhttp3.HttpUrl
|
||||
import at.bitfire.davdroid.databinding.ActivityCreateAddressBookBinding
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.model.Service
|
||||
import at.bitfire.davdroid.ui.account.AccountActivity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.util.*
|
||||
|
||||
class CreateAddressBookActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<CreateAddressBookActivity.AccountInfo> {
|
||||
class CreateAddressBookActivity: AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_ACCOUNT = "account"
|
||||
}
|
||||
|
||||
private lateinit var account: Account
|
||||
|
||||
private lateinit var model: Model
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
account = intent.getParcelableExtra(EXTRA_ACCOUNT)
|
||||
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
setContentView(R.layout.activity_create_address_book)
|
||||
|
||||
loaderManager.initLoader(0, intent.extras, this)
|
||||
model = ViewModelProvider(this).get(Model::class.java)
|
||||
(intent?.getParcelableExtra(EXTRA_ACCOUNT) as? Account)?.let {
|
||||
model.initialize(it)
|
||||
}
|
||||
|
||||
val binding = DataBindingUtil.setContentView<ActivityCreateAddressBookBinding>(this, R.layout.activity_create_address_book)
|
||||
binding.lifecycleOwner = this
|
||||
binding.model = model
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
@@ -56,77 +63,81 @@ class CreateAddressBookActivity: AppCompatActivity(), LoaderManager.LoaderCallba
|
||||
override fun onOptionsItemSelected(item: MenuItem) =
|
||||
if (item.itemId == android.R.id.home) {
|
||||
val intent = Intent(this, AccountActivity::class.java)
|
||||
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
|
||||
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, model.account)
|
||||
NavUtils.navigateUpTo(this, intent)
|
||||
true
|
||||
} else
|
||||
false
|
||||
|
||||
fun onCreateCollection(item: MenuItem) {
|
||||
val homeSet = home_sets.selectedItem as String
|
||||
|
||||
var ok = true
|
||||
HttpUrl.parse(homeSet)?.let {
|
||||
val info = CollectionInfo(it.resolve(UUID.randomUUID().toString() + "/").toString())
|
||||
info.displayName = display_name.text.toString()
|
||||
if (info.displayName.isNullOrBlank()) {
|
||||
display_name.error = getString(R.string.create_collection_display_name_required)
|
||||
ok = false
|
||||
}
|
||||
|
||||
info.description = StringUtils.trimToNull(description.text.toString())
|
||||
val args = Bundle()
|
||||
args.putString(CreateCollectionFragment.ARG_SERVICE_TYPE, Service.TYPE_CARDDAV)
|
||||
|
||||
if (ok) {
|
||||
info.type = CollectionInfo.Type.ADDRESS_BOOK
|
||||
CreateCollectionFragment.newInstance(account, info).show(supportFragmentManager, null)
|
||||
}
|
||||
val parent = model.homeSets.value?.getItem(model.idxHomeSet.value!!) ?: return
|
||||
args.putString(CreateCollectionFragment.ARG_URL, parent.url.resolve(UUID.randomUUID().toString() + "/").toString())
|
||||
|
||||
val displayName = model.displayName.value
|
||||
if (displayName.isNullOrBlank()) {
|
||||
model.displayNameError.value = getString(R.string.create_collection_display_name_required)
|
||||
ok = false
|
||||
} else {
|
||||
args.putString(CreateCollectionFragment.ARG_DISPLAY_NAME, displayName)
|
||||
model.displayNameError.value = null
|
||||
}
|
||||
|
||||
StringUtils.trimToNull(model.description.value)?.let {
|
||||
args.putString(CreateCollectionFragment.ARG_DESCRIPTION, it)
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
args.putParcelable(CreateCollectionFragment.ARG_ACCOUNT, model.account)
|
||||
args.putString(CreateCollectionFragment.ARG_TYPE, Collection.TYPE_ADDRESSBOOK)
|
||||
val frag = CreateCollectionFragment()
|
||||
frag.arguments = args
|
||||
frag.show(supportFragmentManager, null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) = AccountInfoLoader(this, account)
|
||||
class Model(
|
||||
application: Application
|
||||
) : AndroidViewModel(application) {
|
||||
|
||||
override fun onLoadFinished(loader: Loader<AccountInfo>, info: AccountInfo?) {
|
||||
info?.let {
|
||||
home_sets.adapter = ArrayAdapter<String>(this, android.R.layout.simple_spinner_dropdown_item, it.homeSets)
|
||||
}
|
||||
}
|
||||
var account: Account? = null
|
||||
|
||||
override fun onLoaderReset(loader: Loader<AccountInfo>) {
|
||||
}
|
||||
val displayName = MutableLiveData<String>()
|
||||
val displayNameError = MutableLiveData<String>()
|
||||
|
||||
class AccountInfo {
|
||||
val homeSets = LinkedList<String>()
|
||||
}
|
||||
val description = MutableLiveData<String>()
|
||||
|
||||
class AccountInfoLoader(
|
||||
context: Context,
|
||||
val account: Account
|
||||
): AsyncTaskLoader<AccountInfo>(context) {
|
||||
val homeSets = MutableLiveData<HomeSetAdapter>()
|
||||
val idxHomeSet = MutableLiveData<Int>()
|
||||
|
||||
override fun onStartLoading() = forceLoad()
|
||||
@MainThread
|
||||
fun initialize(account: Account) {
|
||||
if (this.account != null)
|
||||
return
|
||||
this.account = account
|
||||
|
||||
override fun loadInBackground(): AccountInfo? {
|
||||
val info = AccountInfo()
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
// find DAV service and home sets
|
||||
val db = dbHelper.readableDatabase
|
||||
db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID),
|
||||
"${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
|
||||
arrayOf(account.name, ServiceDB.Services.SERVICE_CARDDAV), null, null, null).use { cursor ->
|
||||
if (!cursor.moveToNext())
|
||||
return null
|
||||
val strServiceID = cursor.getString(0)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// load account info
|
||||
val adapter = HomeSetAdapter(getApplication())
|
||||
|
||||
db.query(ServiceDB.HomeSets._TABLE, arrayOf(ServiceDB.HomeSets.URL),
|
||||
"${ServiceDB.HomeSets.SERVICE_ID}=?", arrayOf(strServiceID), null, null, null).use { c ->
|
||||
while (c.moveToNext())
|
||||
info.homeSets += c.getString(0)
|
||||
}
|
||||
val db = AppDatabase.getInstance(getApplication())
|
||||
db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV)?.let { service ->
|
||||
val homeSets = db.homeSetDao().getBindableByService(service.id)
|
||||
adapter.addAll(homeSets)
|
||||
}
|
||||
|
||||
if (!adapter.isEmpty) {
|
||||
homeSets.postValue(adapter)
|
||||
idxHomeSet.postValue(0)
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,54 +9,77 @@
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.LoaderManager
|
||||
import android.content.AsyncTaskLoader
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Loader
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.NavUtils
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.Filter
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.NavUtils
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.databinding.ActivityCreateCalendarBinding
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.model.Service
|
||||
import at.bitfire.davdroid.ui.account.AccountActivity
|
||||
import at.bitfire.ical4android.DateUtils
|
||||
import com.jaredrummler.android.colorpicker.ColorPickerDialog
|
||||
import com.jaredrummler.android.colorpicker.ColorPickerDialogListener
|
||||
import kotlinx.android.synthetic.main.activity_create_calendar.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import net.fortuna.ical4j.model.Calendar
|
||||
import okhttp3.HttpUrl
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import yuku.ambilwarna.AmbilWarnaDialog
|
||||
import java.util.*
|
||||
|
||||
class CreateCalendarActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<CreateCalendarActivity.AccountInfo> {
|
||||
class CreateCalendarActivity: AppCompatActivity(), ColorPickerDialogListener {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_ACCOUNT = "account"
|
||||
}
|
||||
|
||||
private lateinit var account: Account
|
||||
private lateinit var model: Model
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
account = intent.extras.getParcelable(EXTRA_ACCOUNT)
|
||||
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
setContentView(R.layout.activity_create_calendar)
|
||||
color.setOnClickListener { _ ->
|
||||
AmbilWarnaDialog(this, (color.background as ColorDrawable).color, true, object: AmbilWarnaDialog.OnAmbilWarnaListener {
|
||||
override fun onCancel(dialog: AmbilWarnaDialog) {}
|
||||
override fun onOk(dialog: AmbilWarnaDialog, rgb: Int) =
|
||||
color.setBackgroundColor(rgb)
|
||||
}).show()
|
||||
model = ViewModelProvider(this).get(Model::class.java)
|
||||
(intent?.getParcelableExtra(EXTRA_ACCOUNT) as? Account)?.let {
|
||||
model.initialize(it)
|
||||
}
|
||||
|
||||
loaderManager.initLoader(0, null, this)
|
||||
val binding = DataBindingUtil.setContentView<ActivityCreateCalendarBinding>(this, R.layout.activity_create_calendar)
|
||||
binding.lifecycleOwner = this
|
||||
binding.model = model
|
||||
|
||||
binding.color.setOnClickListener {
|
||||
ColorPickerDialog.newBuilder()
|
||||
.setShowAlphaSlider(false)
|
||||
.setColor((color.background as ColorDrawable).color)
|
||||
.show(this)
|
||||
}
|
||||
|
||||
binding.timezone.setAdapter(model.timezones)
|
||||
}
|
||||
|
||||
override fun onColorSelected(dialogId: Int, rgb: Int) {
|
||||
model.color.value = rgb
|
||||
}
|
||||
|
||||
override fun onDialogDismissed(dialogId: Int) {
|
||||
// color selection dismissed
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
@@ -67,105 +90,163 @@ class CreateCalendarActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks
|
||||
override fun onOptionsItemSelected(item: MenuItem) =
|
||||
if (item.itemId == android.R.id.home) {
|
||||
val intent = Intent(this, AccountActivity::class.java)
|
||||
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
|
||||
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, model.account)
|
||||
NavUtils.navigateUpTo(this, intent)
|
||||
true
|
||||
} else
|
||||
false
|
||||
|
||||
fun onCreateCollection(item: MenuItem) {
|
||||
val homeSet = home_sets.selectedItem as String
|
||||
|
||||
var ok = true
|
||||
HttpUrl.parse(homeSet)?.let {
|
||||
val info = CollectionInfo(it.resolve(UUID.randomUUID().toString() + "/").toString())
|
||||
info.displayName = display_name.text.toString()
|
||||
if (info.displayName.isNullOrBlank()) {
|
||||
display_name.error = getString(R.string.create_collection_display_name_required)
|
||||
ok = false
|
||||
}
|
||||
|
||||
info.description = StringUtils.trimToNull(description.text.toString())
|
||||
info.color = (color.background as ColorDrawable).color
|
||||
val args = Bundle()
|
||||
args.putString(CreateCollectionFragment.ARG_SERVICE_TYPE, Service.TYPE_CALDAV)
|
||||
|
||||
DateUtils.tzRegistry.getTimeZone(time_zone.selectedItem as String)?.let { tz ->
|
||||
val parent = model.homeSets.value?.getItem(model.idxHomeSet.value!!) ?: return
|
||||
args.putString(CreateCollectionFragment.ARG_URL, parent.url.resolve(UUID.randomUUID().toString() + "/").toString())
|
||||
|
||||
val displayName = model.displayName.value
|
||||
if (displayName.isNullOrBlank()) {
|
||||
model.displayNameError.value = getString(R.string.create_collection_display_name_required)
|
||||
ok = false
|
||||
} else {
|
||||
args.putString(CreateCollectionFragment.ARG_DISPLAY_NAME, displayName)
|
||||
model.displayNameError.value = null
|
||||
}
|
||||
|
||||
StringUtils.trimToNull(model.description.value)?.let {
|
||||
args.putString(CreateCollectionFragment.ARG_DESCRIPTION, it)
|
||||
}
|
||||
|
||||
model.color.value?.let {
|
||||
args.putInt(CreateCollectionFragment.ARG_COLOR, it)
|
||||
}
|
||||
|
||||
val tzId = model.timezone.value
|
||||
if (tzId.isNullOrBlank()) {
|
||||
model.timezoneError.value = getString(R.string.create_calendar_time_zone_required)
|
||||
ok = false
|
||||
} else {
|
||||
DateUtils.tzRegistry.getTimeZone(tzId)?.let { tz ->
|
||||
val cal = Calendar()
|
||||
cal.components += tz.vTimeZone
|
||||
info.timeZone = cal.toString()
|
||||
args.putString(CreateCollectionFragment.ARG_TIMEZONE, cal.toString())
|
||||
}
|
||||
model.timezoneError.value = null
|
||||
}
|
||||
|
||||
when (type.checkedRadioButtonId) {
|
||||
R.id.type_events ->
|
||||
info.supportsVEVENT = true
|
||||
R.id.type_tasks ->
|
||||
info.supportsVTODO = true
|
||||
R.id.type_events_and_tasks -> {
|
||||
info.supportsVEVENT = true
|
||||
info.supportsVTODO = true
|
||||
val supportsVEVENT = model.supportVEVENT.value ?: false
|
||||
val supportsVTODO = model.supportVTODO.value ?: false
|
||||
val supportsVJOURNAL = model.supportVJOURNAL.value ?: false
|
||||
if (!supportsVEVENT && !supportsVTODO && !supportsVJOURNAL) {
|
||||
ok = false
|
||||
model.typeError.value = ""
|
||||
} else
|
||||
model.typeError.value = null
|
||||
|
||||
if (!supportsVEVENT || !supportsVTODO || !supportsVJOURNAL) {
|
||||
// only if there's at least one component set not supported; don't include
|
||||
// information about supported components otherwise (means: everything supported)
|
||||
args.putBoolean(CreateCollectionFragment.ARG_SUPPORTS_VEVENT, supportsVEVENT)
|
||||
args.putBoolean(CreateCollectionFragment.ARG_SUPPORTS_VTODO, supportsVTODO)
|
||||
args.putBoolean(CreateCollectionFragment.ARG_SUPPORTS_VJOURNAL, supportsVJOURNAL)
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
args.putParcelable(CreateCollectionFragment.ARG_ACCOUNT, model.account)
|
||||
args.putString(CreateCollectionFragment.ARG_TYPE, Collection.TYPE_CALENDAR)
|
||||
val frag = CreateCollectionFragment()
|
||||
frag.arguments = args
|
||||
frag.show(supportFragmentManager, null)
|
||||
}
|
||||
}
|
||||
|
||||
class TimeZoneAdapter(
|
||||
context: Context
|
||||
): ArrayAdapter<String>(context, android.R.layout.simple_list_item_1, android.R.id.text1) {
|
||||
|
||||
val tz: Array<String> = TimeZone.getAvailableIDs()
|
||||
|
||||
override fun getFilter(): Filter {
|
||||
return object: Filter() {
|
||||
override fun performFiltering(constraint: CharSequence?): FilterResults {
|
||||
val filtered = constraint?.let {
|
||||
tz.filter { it.contains(constraint, true) }
|
||||
} ?: listOf()
|
||||
val results = FilterResults()
|
||||
results.values = filtered
|
||||
results.count = filtered.size
|
||||
return results
|
||||
}
|
||||
override fun publishResults(constraint: CharSequence?, results: FilterResults) {
|
||||
clear()
|
||||
@Suppress("UNCHECKED_CAST") addAll(results.values as List<String>)
|
||||
if (results.count >= 0)
|
||||
notifyDataSetChanged()
|
||||
else
|
||||
notifyDataSetInvalidated()
|
||||
}
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
info.type = CollectionInfo.Type.CALENDAR
|
||||
CreateCollectionFragment.newInstance(account, info).show(supportFragmentManager, null)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) = AccountInfoLoader(this, account)
|
||||
class Model(
|
||||
application: Application
|
||||
): AndroidViewModel(application) {
|
||||
|
||||
override fun onLoadFinished(loader: Loader<AccountInfo>, info: AccountInfo?) {
|
||||
val timeZones = TimeZone.getAvailableIDs()
|
||||
time_zone.adapter = ArrayAdapter<String>(this, android.R.layout.simple_spinner_dropdown_item, timeZones)
|
||||
var account: Account? = null
|
||||
|
||||
// select system time zone
|
||||
val defaultTimeZone = TimeZone.getDefault().id
|
||||
for (i in 0 until timeZones.size)
|
||||
if (timeZones[i] == defaultTimeZone) {
|
||||
time_zone.setSelection(i)
|
||||
break
|
||||
}
|
||||
val displayName = MutableLiveData<String>()
|
||||
val displayNameError = MutableLiveData<String>()
|
||||
|
||||
info?.let {
|
||||
home_sets.adapter = ArrayAdapter<String>(this, android.R.layout.simple_spinner_dropdown_item, info.homeSets)
|
||||
}
|
||||
}
|
||||
val description = MutableLiveData<String>()
|
||||
val color = MutableLiveData<Int>()
|
||||
|
||||
override fun onLoaderReset(loader: Loader<AccountInfo>) {}
|
||||
val homeSets = MutableLiveData<HomeSetAdapter>()
|
||||
val idxHomeSet = MutableLiveData<Int>()
|
||||
|
||||
val timezones = TimeZoneAdapter(application)
|
||||
val timezone = MutableLiveData<String>()
|
||||
val timezoneError = MutableLiveData<String>()
|
||||
|
||||
class AccountInfo {
|
||||
val homeSets = LinkedList<String>()
|
||||
}
|
||||
val typeError = MutableLiveData<String>()
|
||||
val supportVEVENT = MutableLiveData<Boolean>()
|
||||
val supportVTODO = MutableLiveData<Boolean>()
|
||||
val supportVJOURNAL = MutableLiveData<Boolean>()
|
||||
|
||||
class AccountInfoLoader(
|
||||
context: Context,
|
||||
val account: Account
|
||||
): AsyncTaskLoader<AccountInfo>(context) {
|
||||
@MainThread
|
||||
fun initialize(account: Account) {
|
||||
if (this.account != null)
|
||||
return
|
||||
this.account = account
|
||||
|
||||
override fun onStartLoading() = forceLoad()
|
||||
color.value = Constants.DAVDROID_GREEN_RGBA
|
||||
|
||||
override fun loadInBackground(): AccountInfo? {
|
||||
val info = AccountInfo()
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.readableDatabase
|
||||
db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID),
|
||||
"${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
|
||||
arrayOf(account.name, ServiceDB.Services.SERVICE_CALDAV), null, null, null).use { cursor ->
|
||||
if (!cursor.moveToNext())
|
||||
return null
|
||||
val strServiceID = cursor.getString(0)
|
||||
timezone.value = TimeZone.getDefault().id
|
||||
|
||||
db.query(ServiceDB.HomeSets._TABLE, arrayOf(ServiceDB.HomeSets.URL),
|
||||
"${ServiceDB.HomeSets.SERVICE_ID}=?", arrayOf(strServiceID), null, null, null).use { c ->
|
||||
while (c.moveToNext())
|
||||
info.homeSets += c.getString(0)
|
||||
}
|
||||
supportVEVENT.value = true
|
||||
supportVTODO.value = true
|
||||
supportVJOURNAL.value = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// load account info
|
||||
val adapter = HomeSetAdapter(getApplication())
|
||||
|
||||
val db = AppDatabase.getInstance(getApplication())
|
||||
db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV)?.let { service ->
|
||||
val homeSets = db.homeSetDao().getBindableByService(service.id)
|
||||
adapter.addAll(homeSets)
|
||||
}
|
||||
|
||||
if (!adapter.isEmpty) {
|
||||
homeSets.postValue(adapter)
|
||||
idxHomeSet.postValue(0)
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,100 +9,139 @@
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.Dialog
|
||||
import android.app.ProgressDialog
|
||||
import android.content.Context
|
||||
import android.app.Application
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.DialogFragment
|
||||
import android.support.v4.app.LoaderManager
|
||||
import android.support.v4.content.AsyncTaskLoader
|
||||
import android.support.v4.content.Loader
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.XmlUtils
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.lifecycle.*
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import 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.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import okhttp3.HttpUrl
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import java.io.IOException
|
||||
import java.io.StringWriter
|
||||
import java.util.logging.Level
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
class CreateCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<Exception> {
|
||||
class CreateCollectionFragment: DialogFragment() {
|
||||
|
||||
companion object {
|
||||
|
||||
const val ARG_ACCOUNT = "account"
|
||||
const val ARG_COLLECTION_INFO = "collectionInfo"
|
||||
const val ARG_SERVICE_TYPE = "serviceType"
|
||||
|
||||
fun newInstance(account: Account, info: CollectionInfo): CreateCollectionFragment {
|
||||
val frag = CreateCollectionFragment()
|
||||
val args = Bundle(2)
|
||||
args.putParcelable(ARG_ACCOUNT, account)
|
||||
args.putSerializable(ARG_COLLECTION_INFO, info)
|
||||
frag.arguments = args
|
||||
return frag
|
||||
}
|
||||
const val ARG_TYPE = "type"
|
||||
const val ARG_URL = "url"
|
||||
const val ARG_DISPLAY_NAME = "displayName"
|
||||
const val ARG_DESCRIPTION = "description"
|
||||
|
||||
// CalDAV only
|
||||
const val ARG_COLOR = "color"
|
||||
const val ARG_TIMEZONE = "timezone"
|
||||
const val ARG_SUPPORTS_VEVENT = "supportsVEVENT"
|
||||
const val ARG_SUPPORTS_VTODO = "supportsVTODO"
|
||||
const val ARG_SUPPORTS_VJOURNAL = "supportsVJOURNAL"
|
||||
}
|
||||
|
||||
private lateinit var account: Account
|
||||
private lateinit var info: CollectionInfo
|
||||
private lateinit var model: Model
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val args = arguments ?: throw IllegalArgumentException()
|
||||
|
||||
val args = requireNotNull(arguments)
|
||||
account = args.getParcelable(ARG_ACCOUNT)
|
||||
info = args.getSerializable(ARG_COLLECTION_INFO) as CollectionInfo
|
||||
model = ViewModelProvider(this).get(Model::class.java)
|
||||
model.account = args.getParcelable(ARG_ACCOUNT) ?: throw IllegalArgumentException("ARG_ACCOUNT required")
|
||||
model.serviceType = args.getString(ARG_SERVICE_TYPE) ?: throw java.lang.IllegalArgumentException("ARG_SERVICE_TYPE required")
|
||||
|
||||
loaderManager.initLoader(0, null, this)
|
||||
}
|
||||
model.collection = Collection(
|
||||
type = args.getString(ARG_TYPE) ?: throw IllegalArgumentException("ARG_TYPE required"),
|
||||
url = (args.getString(ARG_URL) ?: throw IllegalArgumentException("ARG_URL required")).toHttpUrl(),
|
||||
displayName = args.getString(ARG_DISPLAY_NAME),
|
||||
description = args.getString(ARG_DESCRIPTION),
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val progress = ProgressDialog(context)
|
||||
progress.setTitle(R.string.create_collection_creating)
|
||||
progress.setMessage(getString(R.string.please_wait))
|
||||
progress.isIndeterminate = true
|
||||
progress.setCanceledOnTouchOutside(false)
|
||||
isCancelable = false
|
||||
return progress
|
||||
}
|
||||
color = args.ifDefined(ARG_COLOR) { it.getInt(ARG_COLOR) },
|
||||
timezone = args.getString(ARG_TIMEZONE),
|
||||
supportsVEVENT = args.ifDefined(ARG_SUPPORTS_VEVENT) { it.getBoolean(ARG_SUPPORTS_VEVENT) },
|
||||
supportsVTODO = args.ifDefined(ARG_SUPPORTS_VTODO) { it.getBoolean(ARG_SUPPORTS_VTODO) },
|
||||
supportsVJOURNAL = args.ifDefined(ARG_SUPPORTS_VJOURNAL) { it.getBoolean(ARG_SUPPORTS_VJOURNAL) },
|
||||
|
||||
sync = true /* by default, sync collections which just have been created */
|
||||
)
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) = CreateCollectionLoader(requireActivity(), account, info)
|
||||
|
||||
override fun onLoadFinished(loader: Loader<Exception>, exception: Exception?) {
|
||||
dismiss()
|
||||
|
||||
activity?.let { parent ->
|
||||
if (exception != null)
|
||||
requireFragmentManager().beginTransaction()
|
||||
.add(ExceptionInfoFragment.newInstance(exception, account), null)
|
||||
model.createCollection().observe(this, Observer { exception ->
|
||||
if (exception == null)
|
||||
requireActivity().finish()
|
||||
else {
|
||||
dismiss()
|
||||
parentFragmentManager.beginTransaction()
|
||||
.add(ExceptionInfoFragment.newInstance(exception, model.account), null)
|
||||
.commit()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun<T> Bundle.ifDefined(name: String, getter: (Bundle) -> T): T? =
|
||||
if (containsKey(name))
|
||||
getter(this)
|
||||
else
|
||||
parent.finish()
|
||||
null
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val v = inflater.inflate(R.layout.create_collection, container, false)
|
||||
isCancelable = false
|
||||
return v
|
||||
}
|
||||
|
||||
|
||||
class Model(
|
||||
app: Application
|
||||
): AndroidViewModel(app) {
|
||||
|
||||
lateinit var account: Account
|
||||
lateinit var serviceType: String
|
||||
lateinit var collection: Collection
|
||||
|
||||
val result = MutableLiveData<Exception>()
|
||||
|
||||
fun createCollection(): LiveData<Exception> {
|
||||
viewModelScope.launch(Dispatchers.IO + NonCancellable) {
|
||||
HttpClient.Builder(getApplication(), AccountSettings(getApplication(), account))
|
||||
.setForeground(true)
|
||||
.build().use { httpClient ->
|
||||
try {
|
||||
val dav = DavResource(httpClient.okHttpClient, collection.url)
|
||||
|
||||
// create collection on remote server
|
||||
dav.mkCol(generateXml()) {}
|
||||
|
||||
// no HTTP error -> create collection locally
|
||||
val db = AppDatabase.getInstance(getApplication())
|
||||
db.serviceDao().getByAccountAndType(account.name, serviceType)?.let { service ->
|
||||
collection.serviceId = service.id
|
||||
db.collectionDao().insert(collection)
|
||||
}
|
||||
|
||||
// post success
|
||||
result.postValue(null)
|
||||
} catch (e: Exception) {
|
||||
// post error
|
||||
result.postValue(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<Exception>) {}
|
||||
|
||||
|
||||
class CreateCollectionLoader(
|
||||
context: Context,
|
||||
val account: Account,
|
||||
val info: CollectionInfo
|
||||
): AsyncTaskLoader<Exception>(context) {
|
||||
|
||||
override fun onStartLoading() = forceLoad()
|
||||
|
||||
override fun loadInBackground(): Exception? {
|
||||
private fun generateXml(): String {
|
||||
val writer = StringWriter()
|
||||
try {
|
||||
val serializer = XmlUtils.newSerializer()
|
||||
@@ -112,72 +151,80 @@ class CreateCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<
|
||||
setPrefix("", XmlUtils.NS_WEBDAV)
|
||||
setPrefix("CAL", XmlUtils.NS_CALDAV)
|
||||
setPrefix("CARD", XmlUtils.NS_CARDDAV)
|
||||
|
||||
|
||||
startTag(XmlUtils.NS_WEBDAV, "mkcol")
|
||||
startTag(XmlUtils.NS_WEBDAV, "set")
|
||||
startTag(XmlUtils.NS_WEBDAV, "prop")
|
||||
startTag(XmlUtils.NS_WEBDAV, "resourcetype")
|
||||
startTag(XmlUtils.NS_WEBDAV, "collection")
|
||||
endTag(XmlUtils.NS_WEBDAV, "collection")
|
||||
if (info.type == CollectionInfo.Type.ADDRESS_BOOK) {
|
||||
startTag(XmlUtils.NS_CARDDAV, "addressbook")
|
||||
endTag(XmlUtils.NS_CARDDAV, "addressbook")
|
||||
} else if (info.type == CollectionInfo.Type.CALENDAR) {
|
||||
startTag(XmlUtils.NS_CALDAV, "calendar")
|
||||
endTag(XmlUtils.NS_CALDAV, "calendar")
|
||||
}
|
||||
endTag(XmlUtils.NS_WEBDAV, "resourcetype")
|
||||
info.displayName?.let {
|
||||
startTag(XmlUtils.NS_WEBDAV, "displayname")
|
||||
text(it)
|
||||
endTag(XmlUtils.NS_WEBDAV, "displayname")
|
||||
}
|
||||
|
||||
// addressbook-specific properties
|
||||
if (info.type == CollectionInfo.Type.ADDRESS_BOOK) {
|
||||
info.description?.let {
|
||||
startTag(XmlUtils.NS_CARDDAV, "addressbook-description")
|
||||
text(it)
|
||||
endTag(XmlUtils.NS_CARDDAV, "addressbook-description")
|
||||
}
|
||||
}
|
||||
|
||||
// calendar-specific properties
|
||||
if (info.type == CollectionInfo.Type.CALENDAR) {
|
||||
info.description?.let {
|
||||
startTag(XmlUtils.NS_CALDAV, "calendar-description")
|
||||
text(it)
|
||||
endTag(XmlUtils.NS_CALDAV, "calendar-description")
|
||||
}
|
||||
startTag(XmlUtils.NS_WEBDAV, "set")
|
||||
startTag(XmlUtils.NS_WEBDAV, "prop")
|
||||
startTag(XmlUtils.NS_WEBDAV, "resourcetype")
|
||||
startTag(XmlUtils.NS_WEBDAV, "collection")
|
||||
endTag(XmlUtils.NS_WEBDAV, "collection")
|
||||
if (collection.type == Collection.TYPE_ADDRESSBOOK) {
|
||||
startTag(XmlUtils.NS_CARDDAV, "addressbook")
|
||||
endTag(XmlUtils.NS_CARDDAV, "addressbook")
|
||||
} else if (collection.type == Collection.TYPE_CALENDAR) {
|
||||
startTag(XmlUtils.NS_CALDAV, "calendar")
|
||||
endTag(XmlUtils.NS_CALDAV, "calendar")
|
||||
}
|
||||
endTag(XmlUtils.NS_WEBDAV, "resourcetype")
|
||||
collection.displayName?.let {
|
||||
startTag(XmlUtils.NS_WEBDAV, "displayname")
|
||||
text(it)
|
||||
endTag(XmlUtils.NS_WEBDAV, "displayname")
|
||||
}
|
||||
|
||||
info.color?.let {
|
||||
startTag(XmlUtils.NS_APPLE_ICAL, "calendar-color")
|
||||
text(DavUtils.ARGBtoCalDAVColor(it))
|
||||
endTag(XmlUtils.NS_APPLE_ICAL, "calendar-color")
|
||||
}
|
||||
// addressbook-specific properties
|
||||
if (collection.type == Collection.TYPE_ADDRESSBOOK) {
|
||||
collection.description?.let {
|
||||
startTag(XmlUtils.NS_CARDDAV, "addressbook-description")
|
||||
text(it)
|
||||
endTag(XmlUtils.NS_CARDDAV, "addressbook-description")
|
||||
}
|
||||
}
|
||||
|
||||
info.timeZone?.let {
|
||||
startTag(XmlUtils.NS_CALDAV, "calendar-timezone")
|
||||
cdsect(it)
|
||||
endTag(XmlUtils.NS_CALDAV, "calendar-timezone")
|
||||
}
|
||||
|
||||
startTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set")
|
||||
if (info.supportsVEVENT) {
|
||||
startTag(XmlUtils.NS_CALDAV, "comp")
|
||||
attribute(null, "name", "VEVENT")
|
||||
endTag(XmlUtils.NS_CALDAV, "comp")
|
||||
}
|
||||
if (info.supportsVTODO) {
|
||||
startTag(XmlUtils.NS_CALDAV, "comp")
|
||||
attribute(null, "name", "VTODO")
|
||||
endTag(XmlUtils.NS_CALDAV, "comp")
|
||||
}
|
||||
endTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set")
|
||||
}
|
||||
|
||||
endTag(XmlUtils.NS_WEBDAV, "prop")
|
||||
endTag(XmlUtils.NS_WEBDAV, "set")
|
||||
// calendar-specific properties
|
||||
if (collection.type == Collection.TYPE_CALENDAR) {
|
||||
collection.description?.let {
|
||||
startTag(XmlUtils.NS_CALDAV, "calendar-description")
|
||||
text(it)
|
||||
endTag(XmlUtils.NS_CALDAV, "calendar-description")
|
||||
}
|
||||
|
||||
collection.color?.let {
|
||||
startTag(XmlUtils.NS_APPLE_ICAL, "calendar-color")
|
||||
text(DavUtils.ARGBtoCalDAVColor(it))
|
||||
endTag(XmlUtils.NS_APPLE_ICAL, "calendar-color")
|
||||
}
|
||||
|
||||
collection.timezone?.let {
|
||||
startTag(XmlUtils.NS_CALDAV, "calendar-timezone")
|
||||
cdsect(it)
|
||||
endTag(XmlUtils.NS_CALDAV, "calendar-timezone")
|
||||
}
|
||||
|
||||
if (collection.supportsVEVENT != null || collection.supportsVTODO != null || collection.supportsVJOURNAL != null) {
|
||||
// only if there's at least one explicitly supported calendar component set, otherwise don't include the property
|
||||
startTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set")
|
||||
if (collection.supportsVEVENT != false) {
|
||||
startTag(XmlUtils.NS_CALDAV, "comp")
|
||||
attribute(null, "name", "VEVENT")
|
||||
endTag(XmlUtils.NS_CALDAV, "comp")
|
||||
}
|
||||
if (collection.supportsVTODO != false) {
|
||||
startTag(XmlUtils.NS_CALDAV, "comp")
|
||||
attribute(null, "name", "VTODO")
|
||||
endTag(XmlUtils.NS_CALDAV, "comp")
|
||||
}
|
||||
if (collection.supportsVJOURNAL != false) {
|
||||
startTag(XmlUtils.NS_CALDAV, "comp")
|
||||
attribute(null, "name", "VJOURNAL")
|
||||
endTag(XmlUtils.NS_CALDAV, "comp")
|
||||
}
|
||||
endTag(XmlUtils.NS_CALDAV, "supported-calendar-component-set")
|
||||
}
|
||||
}
|
||||
|
||||
endTag(XmlUtils.NS_WEBDAV, "prop")
|
||||
endTag(XmlUtils.NS_WEBDAV, "set")
|
||||
endTag(XmlUtils.NS_WEBDAV, "mkcol")
|
||||
endDocument()
|
||||
}
|
||||
@@ -185,46 +232,9 @@ class CreateCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<
|
||||
Logger.log.log(Level.SEVERE, "Couldn't assemble Extended MKCOL request", e)
|
||||
}
|
||||
|
||||
Settings.getInstance(context)?.use { settings ->
|
||||
HttpClient.Builder(context, settings, AccountSettings(context, settings, account))
|
||||
.setForeground(true)
|
||||
.build().use { httpClient ->
|
||||
try {
|
||||
val collection = DavResource(httpClient.okHttpClient, HttpUrl.parse(info.url)!!)
|
||||
|
||||
// create collection on remote server
|
||||
collection.mkCol(writer.toString())
|
||||
|
||||
// now insert collection into database:
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.writableDatabase
|
||||
|
||||
// 1. find service ID
|
||||
val serviceType = when (info.type) {
|
||||
CollectionInfo.Type.ADDRESS_BOOK -> ServiceDB.Services.SERVICE_CARDDAV
|
||||
CollectionInfo.Type.CALENDAR -> ServiceDB.Services.SERVICE_CALDAV
|
||||
else -> throw IllegalArgumentException("Collection must be an address book or calendar")
|
||||
}
|
||||
db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID),
|
||||
"${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
|
||||
arrayOf(account.name, serviceType), null, null, null).use { c ->
|
||||
|
||||
assert(c.moveToNext())
|
||||
val serviceID = c.getLong(0)
|
||||
|
||||
// 2. add collection to service
|
||||
val values = info.toDB()
|
||||
values.put(ServiceDB.Collections.SERVICE_ID, serviceID)
|
||||
db.insert(ServiceDB.Collections._TABLE, null, values)
|
||||
}
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
return writer.toString()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -11,9 +11,12 @@ package at.bitfire.davdroid.ui
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Application
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
@@ -21,52 +24,62 @@ import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.support.v4.app.LoaderManager
|
||||
import android.support.v4.content.AsyncTaskLoader
|
||||
import android.support.v4.content.ContextCompat
|
||||
import android.support.v4.content.FileProvider
|
||||
import android.support.v4.content.Loader
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.util.Log
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import at.bitfire.dav4android.exception.HttpException
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.databinding.ActivityDebugInfoBinding
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import kotlinx.android.synthetic.main.activity_debug_info.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.dmfs.tasks.contract.TaskContract
|
||||
import java.io.File
|
||||
import java.io.FileWriter
|
||||
import java.io.IOException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
class DebugInfoActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<String> {
|
||||
class DebugInfoActivity: AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
const val KEY_THROWABLE = "throwable"
|
||||
const val KEY_LOGS = "logs"
|
||||
const val KEY_ACCOUNT = "account"
|
||||
const val KEY_AUTHORITY = "authority"
|
||||
const val KEY_PHASE = "phase"
|
||||
const val KEY_LOCAL_RESOURCE = "localResource"
|
||||
const val KEY_REMOTE_RESOURCE = "remoteResource"
|
||||
}
|
||||
|
||||
private var report: String? = null
|
||||
private lateinit var model: ReportModel
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_debug_info)
|
||||
|
||||
supportLoaderManager.initLoader(0, intent.extras, this)
|
||||
model = ViewModelProvider(this).get(ReportModel::class.java)
|
||||
model.initialize(intent.extras)
|
||||
|
||||
val binding = DataBindingUtil.setContentView<ActivityDebugInfoBinding>(this, R.layout.activity_debug_info)
|
||||
binding.model = model
|
||||
binding.lifecycleOwner = this
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
@@ -76,215 +89,260 @@ class DebugInfoActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<Stri
|
||||
|
||||
|
||||
fun onShare(item: MenuItem) {
|
||||
report?.let {
|
||||
val sendIntent = Intent()
|
||||
sendIntent.action = Intent.ACTION_SEND
|
||||
sendIntent.type = "text/plain"
|
||||
sendIntent.putExtra(Intent.EXTRA_SUBJECT, "${getString(R.string.app_name)} ${BuildConfig.VERSION_NAME} debug info")
|
||||
model.report.value?.let { report ->
|
||||
val builder = ShareCompat.IntentBuilder.from(this)
|
||||
.setSubject("${getString(R.string.app_name)} ${BuildConfig.VERSION_NAME} debug info")
|
||||
.setText(getString(R.string.debug_info_logs_attached))
|
||||
.setType("text/plain")
|
||||
|
||||
try {
|
||||
val debugInfoDir = File(cacheDir, "debug-info")
|
||||
debugInfoDir.mkdir()
|
||||
val debugInfoDir = File(filesDir, "debug")
|
||||
if (!(debugInfoDir.exists() && debugInfoDir.isDirectory) && !debugInfoDir.mkdir())
|
||||
throw IOException("Couldn't create debug directory")
|
||||
|
||||
val reportFile = File(debugInfoDir, "debug.txt")
|
||||
val reportFile = File(debugInfoDir, "davx5-info.txt")
|
||||
Logger.log.fine("Writing debug info to ${reportFile.absolutePath}")
|
||||
val writer = FileWriter(reportFile)
|
||||
writer.write(it)
|
||||
writer.write(report)
|
||||
writer.close()
|
||||
|
||||
sendIntent.putExtra(Intent.EXTRA_STREAM, FileProvider.getUriForFile(this, getString(R.string.authority_log_provider), reportFile))
|
||||
sendIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
|
||||
builder.setStream(FileProvider.getUriForFile(this, getString(R.string.authority_debug_provider), reportFile))
|
||||
builder.intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
} catch(e: IOException) {
|
||||
// creating an attachment failed, so send it inline
|
||||
sendIntent.putExtra(Intent.EXTRA_TEXT, it)
|
||||
val text = "Couldn't create debug info file: " + Log.getStackTraceString(e) + "\n\n$report"
|
||||
builder.setText(text)
|
||||
}
|
||||
|
||||
startActivity(Intent.createChooser(sendIntent, null))
|
||||
builder.startChooser()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) =
|
||||
ReportLoader(this, args)
|
||||
class ReportModel(application: Application): AndroidViewModel(application) {
|
||||
|
||||
override fun onLoadFinished(loader: Loader<String>, data: String?) {
|
||||
data?.let {
|
||||
report = it
|
||||
text_report.text = it
|
||||
}
|
||||
}
|
||||
private var initialized = false
|
||||
val report = MutableLiveData<String>()
|
||||
|
||||
override fun onLoaderReset(loader: Loader<String>) {}
|
||||
fun initialize(extras: Bundle?) {
|
||||
if (initialized)
|
||||
return
|
||||
|
||||
Logger.log.info("Generating debug info report")
|
||||
initialized = true
|
||||
|
||||
class ReportLoader(
|
||||
context: Context,
|
||||
val extras: Bundle?
|
||||
): AsyncTaskLoader<String>(context) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val context = getApplication<Application>()
|
||||
val text = StringBuilder("--- BEGIN DEBUG INFO ---\n")
|
||||
|
||||
var result: String? = null
|
||||
|
||||
override fun onStartLoading() {
|
||||
if (result != null)
|
||||
deliverResult(result)
|
||||
else
|
||||
forceLoad()
|
||||
}
|
||||
|
||||
override fun loadInBackground(): String {
|
||||
Logger.log.info("Building debug info")
|
||||
val report = StringBuilder("--- BEGIN DEBUG INFO ---\n")
|
||||
|
||||
// begin with most specific information
|
||||
extras?.getInt(KEY_PHASE, -1).takeIf { it != -1 }?.let {
|
||||
report.append("SYNCHRONIZATION INFO\nSynchronization phase: $it\n")
|
||||
}
|
||||
extras?.getParcelable<Account>(KEY_ACCOUNT)?.let {
|
||||
report.append("Account name: ${it.name}\n")
|
||||
}
|
||||
extras?.getString(KEY_AUTHORITY)?.let {
|
||||
report.append("Authority: $it\n")
|
||||
}
|
||||
|
||||
// exception details
|
||||
val throwable = extras?.getSerializable(KEY_THROWABLE) as Throwable?
|
||||
if (throwable is HttpException) {
|
||||
throwable.request?.let {
|
||||
report.append("\nHTTP REQUEST:\n$it\n\n")
|
||||
// begin with most specific information
|
||||
extras?.getParcelable<Account>(KEY_ACCOUNT)?.let {
|
||||
text.append("SYNCHRONIZATION INFO\nAccount name: ${it.name}\n")
|
||||
}
|
||||
throwable.response?.let {
|
||||
report.append("HTTP RESPONSE:\n$it\n")
|
||||
extras?.getString(KEY_AUTHORITY)?.let {
|
||||
text.append("Authority: $it\n")
|
||||
}
|
||||
}
|
||||
|
||||
extras?.getString(KEY_LOCAL_RESOURCE)?.let {
|
||||
report.append("\nLOCAL RESOURCE:\n$it\n")
|
||||
}
|
||||
extras?.getString(KEY_REMOTE_RESOURCE)?.let {
|
||||
report.append("\nREMOTE RESOURCE:\n$it\n")
|
||||
}
|
||||
// exception details
|
||||
val throwable = extras?.getSerializable(KEY_THROWABLE) as Throwable?
|
||||
if (throwable is HttpException) {
|
||||
throwable.request?.let { request ->
|
||||
text.append("\nHTTP REQUEST:\n$request\n")
|
||||
throwable.requestBody?.let { text.append(it) }
|
||||
text.append("\n\n")
|
||||
}
|
||||
throwable.response?.let { response ->
|
||||
text.append("HTTP RESPONSE:\n$response\n")
|
||||
throwable.responseBody?.let { text.append(it) }
|
||||
text.append("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
throwable?.let {
|
||||
report.append("\nEXCEPTION:\n${Log.getStackTraceString(throwable)}")
|
||||
}
|
||||
extras?.getString(KEY_LOCAL_RESOURCE)?.let {
|
||||
text.append("\nLOCAL RESOURCE:\n$it\n")
|
||||
}
|
||||
extras?.getString(KEY_REMOTE_RESOURCE)?.let {
|
||||
text.append("\nREMOTE RESOURCE:\n$it\n")
|
||||
}
|
||||
|
||||
// logs (for instance, from failed resource detection)
|
||||
extras?.getString(KEY_LOGS)?.let {
|
||||
report.append("\nLOGS:\n$it\n")
|
||||
}
|
||||
throwable?.let {
|
||||
// Log.getStackTraceString(e) returns "" in case of UnknownHostException
|
||||
text.append("\nEXCEPTION:\n${ExceptionUtils.getStackTrace(throwable)}")
|
||||
}
|
||||
|
||||
// software information
|
||||
try {
|
||||
val pm = context.packageManager
|
||||
val installedFrom = pm.getInstallerPackageName(BuildConfig.APPLICATION_ID) ?: "APK (directly)"
|
||||
var workaroundInstalled = false
|
||||
// logs (for instance, from failed resource detection)
|
||||
extras?.getString(KEY_LOGS)?.let {
|
||||
text.append("\nLOGS:\n$it\n")
|
||||
}
|
||||
|
||||
// software information
|
||||
try {
|
||||
workaroundInstalled = pm.getPackageInfo("${BuildConfig.APPLICATION_ID}.jbworkaround", 0) != null
|
||||
} catch(e: PackageManager.NameNotFoundException) {
|
||||
}
|
||||
val formatter = SimpleDateFormat.getDateInstance()
|
||||
report.append("\nSOFTWARE INFORMATION\n" +
|
||||
"Package: ${BuildConfig.APPLICATION_ID}\n" +
|
||||
"Version: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) from ${formatter.format(Date(BuildConfig.buildTime))}\n")
|
||||
.append("Installed from: $installedFrom\n")
|
||||
.append("JB Workaround installed: ${if (workaroundInstalled) "yes" else "no"}\n\n")
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't get software information", e)
|
||||
}
|
||||
text.append("\nSOFTWARE INFORMATION\n")
|
||||
val pm = context.packageManager
|
||||
val appIDs = mutableSetOf( // we always want info about these packages
|
||||
BuildConfig.APPLICATION_ID, // DAVx5
|
||||
"${BuildConfig.APPLICATION_ID}.jbworkaround", // DAVdroid JB Workaround
|
||||
"org.dmfs.tasks" // OpenTasks
|
||||
)
|
||||
// add info about contact, calendar, task provider
|
||||
for (authority in arrayOf(ContactsContract.AUTHORITY, CalendarContract.AUTHORITY, TaskProvider.ProviderName.OpenTasks.authority))
|
||||
pm.resolveContentProvider(authority, 0)?.let { appIDs += it.packageName }
|
||||
// add info about available contact, calendar, task apps
|
||||
for (uri in arrayOf(ContactsContract.Contacts.CONTENT_URI, CalendarContract.Events.CONTENT_URI, TaskContract.Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority))) {
|
||||
val viewIntent = Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(uri, 1))
|
||||
for (info in pm.queryIntentActivities(viewIntent, 0))
|
||||
appIDs += info.activityInfo.packageName
|
||||
}
|
||||
|
||||
// connectivity
|
||||
report.append("CONNECTIVITY (at the moment)\n")
|
||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
connectivityManager.activeNetworkInfo?.let { networkInfo ->
|
||||
val type = when (networkInfo.type) {
|
||||
ConnectivityManager.TYPE_WIFI -> "WiFi"
|
||||
ConnectivityManager.TYPE_MOBILE -> "mobile"
|
||||
else -> "type: ${networkInfo.type}"
|
||||
for (appID in appIDs)
|
||||
try {
|
||||
val info = pm.getPackageInfo(appID, 0)
|
||||
text .append("* ").append(appID)
|
||||
.append(" ").append(info.versionName)
|
||||
.append(" (").append(PackageInfoCompat.getLongVersionCode(info)).append(")")
|
||||
pm.getInstallerPackageName(appID)?.let { installer ->
|
||||
text.append(" from ").append(installer)
|
||||
}
|
||||
info.applicationInfo?.let { applicationInfo ->
|
||||
if (!applicationInfo.enabled)
|
||||
text.append(" disabled!")
|
||||
if (applicationInfo.flags.and(ApplicationInfo.FLAG_EXTERNAL_STORAGE) != 0)
|
||||
text.append(" on external storage!")
|
||||
}
|
||||
text.append("\n")
|
||||
} catch(e: PackageManager.NameNotFoundException) {
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't get software information", e)
|
||||
}
|
||||
report.append("Active connection: $type, ${networkInfo.detailedState}\n")
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 23)
|
||||
connectivityManager.defaultProxy?.let { proxy ->
|
||||
report.append("System default proxy: ${proxy.host}:${proxy.port}")
|
||||
}
|
||||
report.append("\n")
|
||||
|
||||
report.append("CONFIGURATION\n")
|
||||
// power saving
|
||||
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
if (Build.VERSION.SDK_INT >= 23)
|
||||
report.append("Power saving disabled: ")
|
||||
.append(if (powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)) "yes" else "no")
|
||||
.append("\n")
|
||||
// permissions
|
||||
for (permission in arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS,
|
||||
Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR,
|
||||
TaskProvider.PERMISSION_READ_TASKS, TaskProvider.PERMISSION_WRITE_TASKS))
|
||||
report.append(permission).append(" permission: ")
|
||||
.append(if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED) "granted" else "denied")
|
||||
.append("\n")
|
||||
// system-wide sync settings
|
||||
report.append("System-wide synchronization: ")
|
||||
.append(if (ContentResolver.getMasterSyncAutomatically()) "automatically" else "manually")
|
||||
.append("\n")
|
||||
// main accounts
|
||||
val accountManager = AccountManager.get(context)
|
||||
Settings.getInstance(context)?.let { settings ->
|
||||
for (acct in accountManager.getAccountsByType(context.getString(R.string.account_type))) {
|
||||
// connectivity
|
||||
text.append("\nCONNECTIVITY (at the moment)\n")
|
||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
connectivityManager.allNetworks.forEach { network ->
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(network)
|
||||
text.append("- $capabilities\n")
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 23)
|
||||
connectivityManager.defaultProxy?.let { proxy ->
|
||||
text.append("System default proxy: ${proxy.host}:${proxy.port}\n")
|
||||
}
|
||||
text.append("\n")
|
||||
|
||||
text.append("CONFIGURATION\n")
|
||||
// power saving
|
||||
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
if (Build.VERSION.SDK_INT >= 23)
|
||||
text.append("Power saving disabled: ")
|
||||
.append(if (powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)) "yes" else "no")
|
||||
.append("\n")
|
||||
// notifications
|
||||
val nm = NotificationManagerCompat.from(context)
|
||||
text.append("Notifications (")
|
||||
text.append(if (nm.areNotificationsEnabled())
|
||||
"not blocked"
|
||||
else
|
||||
"BLOCKED!")
|
||||
text.append("):\n")
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
val channelsWithoutGroup = nm.notificationChannels.toMutableSet()
|
||||
for (group in nm.notificationChannelGroups) {
|
||||
text.append(" [group] ${group.id}")
|
||||
if (Build.VERSION.SDK_INT >= 28)
|
||||
text.append(" isBlocked=${group.isBlocked}")
|
||||
text.append("\n")
|
||||
|
||||
for (channel in group.channels) {
|
||||
text.append(" ${channel.id}: importance=${channel.importance}\n")
|
||||
channelsWithoutGroup -= channel
|
||||
}
|
||||
}
|
||||
for (channel in channelsWithoutGroup)
|
||||
text.append(" ${channel.id}: importance=${channel.importance}\n")
|
||||
}
|
||||
// permissions
|
||||
text.append("Permissions:\n")
|
||||
for (permission in arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS,
|
||||
Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR,
|
||||
TaskProvider.PERMISSION_READ_TASKS, TaskProvider.PERMISSION_WRITE_TASKS,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION)) {
|
||||
val shortPermission = permission.replace(Regex("^.+\\.permission\\."), "")
|
||||
text .append(" $shortPermission: ")
|
||||
.append(if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED)
|
||||
"granted"
|
||||
else
|
||||
"denied")
|
||||
.append("\n")
|
||||
}
|
||||
// system-wide sync settings
|
||||
text .append("System-wide synchronization: ")
|
||||
.append(if (ContentResolver.getMasterSyncAutomatically()) "automatically" else "manually")
|
||||
.append("\n\n")
|
||||
|
||||
// app settings
|
||||
val settings = Settings(context)
|
||||
val overrideProxy = settings.getBoolean(Settings.OVERRIDE_PROXY)
|
||||
text .append("APP SETTINGS\n")
|
||||
.append("Distrust system certs: ${settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES) ?: Settings.DISTRUST_SYSTEM_CERTIFICATES_DEFAULT}\n")
|
||||
.append("Override system proxy: $overrideProxy\n")
|
||||
if (overrideProxy == true)
|
||||
text.append(" Proxy: ${settings.getString(Settings.OVERRIDE_PROXY_HOST) ?: Settings.OVERRIDE_PROXY_HOST_DEFAULT}:${settings.getInt(Settings.OVERRIDE_PROXY_PORT) ?: Settings.OVERRIDE_PROXY_PORT_DEFAULT}\n")
|
||||
text.append("\n")
|
||||
|
||||
// main accounts
|
||||
text.append("ACCOUNTS\n")
|
||||
val accountManager = AccountManager.get(context)
|
||||
for (acct in accountManager.getAccountsByType(context.getString(R.string.account_type)))
|
||||
try {
|
||||
val accountSettings = AccountSettings(context, settings, acct)
|
||||
report.append("Account: ${acct.name}\n" +
|
||||
val accountSettings = AccountSettings(context, acct)
|
||||
text.append("Account: ${acct.name}\n" +
|
||||
" Address book sync. interval: ${syncStatus(accountSettings, context.getString(R.string.address_books_authority))}\n" +
|
||||
" Calendar sync. interval: ${syncStatus(accountSettings, CalendarContract.AUTHORITY)}\n" +
|
||||
" OpenTasks sync. interval: ${syncStatus(accountSettings, TaskProvider.ProviderName.OpenTasks.authority)}\n" +
|
||||
" WiFi only: ").append(accountSettings.getSyncWifiOnly())
|
||||
accountSettings.getSyncWifiOnlySSIDs()?.let {
|
||||
report.append(", SSIDs: ${accountSettings.getSyncWifiOnlySSIDs()}")
|
||||
text.append(", SSIDs: ${accountSettings.getSyncWifiOnlySSIDs()}")
|
||||
}
|
||||
report.append("\n [CardDAV] Contact group method: ${accountSettings.getGroupMethod()}")
|
||||
text .append("\n getIsSyncable(CalendarContract): ${ContentResolver.getIsSyncable(acct, CalendarContract.AUTHORITY)}")
|
||||
.append("\n getIsSyncable(OpenTasks): ${ContentResolver.getIsSyncable(acct, TaskProvider.ProviderName.OpenTasks.authority)}")
|
||||
.append("\n [CardDAV] Contact group method: ${accountSettings.getGroupMethod()}")
|
||||
.append("\n [CalDAV] Time range (past days): ${accountSettings.getTimeRangePastDays()}")
|
||||
.append("\n Default alarm (min before): ${accountSettings.getDefaultAlarm()}")
|
||||
.append("\n Manage calendar colors: ${accountSettings.getManageCalendarColors()}")
|
||||
.append("\n Use event colors: ${accountSettings.getEventColors()}")
|
||||
.append("\n")
|
||||
} catch (e: InvalidAccountException) {
|
||||
report.append("$acct is invalid (unsupported settings version) or does not exist\n")
|
||||
text.append("$acct is invalid (unsupported settings version) or does not exist\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
// address book accounts
|
||||
for (acct in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)))
|
||||
// address book accounts
|
||||
for (acct in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)))
|
||||
try {
|
||||
val addressBook = LocalAddressBook(context, acct, null)
|
||||
text.append("Address book account: ${acct.name}\n" +
|
||||
" Main account: ${addressBook.mainAccount}\n" +
|
||||
" URL: ${addressBook.url}\n" +
|
||||
" Sync automatically: ").append(ContentResolver.getSyncAutomatically(acct, ContactsContract.AUTHORITY)).append("\n")
|
||||
} catch(e: Exception) {
|
||||
text.append("$acct is invalid: ${e.message}\n")
|
||||
}
|
||||
text.append("\n")
|
||||
|
||||
text.append("SQLITE DUMP\n")
|
||||
AppDatabase.getInstance(context).dump(text)
|
||||
text.append("\n")
|
||||
|
||||
try {
|
||||
val addressBook = LocalAddressBook(context, acct, null)
|
||||
report.append("Address book account: ${acct.name}\n" +
|
||||
" Main account: ${addressBook.mainAccount}\n" +
|
||||
" URL: ${addressBook.url}\n" +
|
||||
" Sync automatically: ").append(ContentResolver.getSyncAutomatically(acct, ContactsContract.AUTHORITY)).append("\n")
|
||||
text.append(
|
||||
"SYSTEM INFORMATION\n" +
|
||||
"Android version: ${Build.VERSION.RELEASE} (${Build.DISPLAY})\n" +
|
||||
"Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})\n\n"
|
||||
)
|
||||
} catch(e: Exception) {
|
||||
report.append("$acct is invalid: ${e.message}\n")
|
||||
Logger.log.log(Level.SEVERE, "Couldn't get system details", e)
|
||||
}
|
||||
report.append("\n")
|
||||
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
report.append("SQLITE DUMP\n")
|
||||
dbHelper.dump(report)
|
||||
report.append("\n")
|
||||
}
|
||||
|
||||
try {
|
||||
report.append(
|
||||
"SYSTEM INFORMATION\n" +
|
||||
"Android version: ${Build.VERSION.RELEASE} (${Build.DISPLAY})\n" +
|
||||
"Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})\n\n"
|
||||
)
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't get system details", e)
|
||||
}
|
||||
|
||||
report.append("--- END DEBUG INFO ---\n")
|
||||
|
||||
report.toString().let {
|
||||
result = it
|
||||
return it
|
||||
text.append("--- END DEBUG INFO ---\n")
|
||||
report.postValue(text.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +353,7 @@ class DebugInfoActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<Stri
|
||||
} else
|
||||
"—"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,25 +9,28 @@
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.Toast
|
||||
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})"
|
||||
private const val BETA_FEEDBACK_URI = "mailto:play@bitfire.at?subject=${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} feedback (${BuildConfig.VERSION_CODE})"
|
||||
}
|
||||
|
||||
|
||||
override fun onSettingsChanged(settings: ISettings?, menu: Menu) {
|
||||
override fun initMenu(context: Context, menu: Menu) {
|
||||
if (BuildConfig.VERSION_NAME.contains("-beta") || BuildConfig.VERSION_NAME.contains("-rc"))
|
||||
menu.findItem(R.id.nav_beta_feedback).isVisible = true
|
||||
if (/* ose */ true)
|
||||
menu.findItem(R.id.nav_donate).isVisible = true
|
||||
}
|
||||
|
||||
override fun onNavigationItemSelected(activity: Activity, item: MenuItem): Boolean {
|
||||
@@ -36,28 +39,31 @@ class DefaultAccountsDrawerHandler: IAccountsDrawerHandler {
|
||||
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_beta_feedback ->
|
||||
if (!UiUtils.launchUri(activity, Uri.parse(BETA_FEEDBACK_URI), Intent.ACTION_SENDTO, false))
|
||||
Toast.makeText(activity, R.string.install_email_client, Toast.LENGTH_LONG).show()
|
||||
R.id.nav_twitter ->
|
||||
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://twitter.com/davdroidapp")))
|
||||
UiUtils.launchUri(activity,
|
||||
Uri.parse("https://twitter.com/" + activity.getString(R.string.twitter_handle)))
|
||||
R.id.nav_website ->
|
||||
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(activity.getString(R.string.homepage_url))))
|
||||
UiUtils.launchUri(activity,
|
||||
App.homepageUrl(activity))
|
||||
R.id.nav_manual ->
|
||||
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(activity.getString(R.string.homepage_url))
|
||||
.buildUpon().appendEncodedPath("manual/").build()))
|
||||
UiUtils.launchUri(activity,
|
||||
App.homepageUrl(activity).buildUpon().appendPath("manual").build())
|
||||
R.id.nav_faq ->
|
||||
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(activity.getString(R.string.homepage_url))
|
||||
.buildUpon().appendEncodedPath("faq/").build()))
|
||||
UiUtils.launchUri(activity,
|
||||
App.homepageUrl(activity).buildUpon().appendPath("faq").build())
|
||||
R.id.nav_forums ->
|
||||
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(activity.getString(R.string.homepage_url))
|
||||
.buildUpon().appendEncodedPath("forums/").build()))
|
||||
UiUtils.launchUri(activity,
|
||||
App.homepageUrl(activity).buildUpon().appendPath("forums").build())
|
||||
R.id.nav_donate ->
|
||||
//if (BuildConfig.FLAVOR != App.FLAVOR_GOOGLE_PLAY)
|
||||
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(activity.getString(R.string.homepage_url))
|
||||
.buildUpon().appendEncodedPath("donate/").build()))
|
||||
UiUtils.launchUri(activity,
|
||||
App.homepageUrl(activity).buildUpon().appendPath("donate").build())
|
||||
R.id.nav_privacy ->
|
||||
UiUtils.launchUri(activity,
|
||||
App.homepageUrl(activity).buildUpon().appendPath("privacy").build())
|
||||
else ->
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -9,141 +9,131 @@
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.Dialog
|
||||
import android.app.ProgressDialog
|
||||
import android.content.Context
|
||||
import android.app.Application
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.DialogFragment
|
||||
import android.support.v4.app.LoaderManager
|
||||
import android.support.v4.content.AsyncTaskLoader
|
||||
import android.support.v4.content.Loader
|
||||
import android.support.v7.app.AlertDialog
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.lifecycle.*
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.davdroid.HttpClient
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import okhttp3.HttpUrl
|
||||
import at.bitfire.davdroid.databinding.DeleteCollectionBinding
|
||||
import at.bitfire.davdroid.model.AppDatabase
|
||||
import at.bitfire.davdroid.model.Collection
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
class DeleteCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<Exception> {
|
||||
class DeleteCollectionFragment: DialogFragment() {
|
||||
|
||||
companion object {
|
||||
const val ARG_ACCOUNT = "account"
|
||||
const val ARG_COLLECTION_INFO = "collectionInfo"
|
||||
const val ARG_COLLECTION_ID = "collectionId"
|
||||
|
||||
fun newInstance(account: Account, collectionId: Long): DialogFragment {
|
||||
val frag = DeleteCollectionFragment()
|
||||
val args = Bundle(2)
|
||||
args.putParcelable(ARG_ACCOUNT, account)
|
||||
args.putLong(ARG_COLLECTION_ID, collectionId)
|
||||
frag.arguments = args
|
||||
return frag
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var account: Account
|
||||
private lateinit var collectionInfo: CollectionInfo
|
||||
private lateinit var model: DeleteCollectionModel
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
account = arguments!!.getParcelable(ARG_ACCOUNT)
|
||||
collectionInfo = arguments!!.getSerializable(ARG_COLLECTION_INFO) as CollectionInfo
|
||||
|
||||
loaderManager.initLoader(0, null, this)
|
||||
model = ViewModelProvider(this).get(DeleteCollectionModel::class.java)
|
||||
model.initialize(
|
||||
requireArguments().getParcelable(ARG_ACCOUNT)!!,
|
||||
requireArguments().getLong(ARG_COLLECTION_ID)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val progress = ProgressDialog(context)
|
||||
progress.setTitle(R.string.delete_collection_deleting_collection)
|
||||
progress.setMessage(getString(R.string.please_wait))
|
||||
progress.isIndeterminate = true
|
||||
progress.setCanceledOnTouchOutside(false)
|
||||
isCancelable = false
|
||||
return progress
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
val binding = DeleteCollectionBinding.inflate(layoutInflater, null, false)
|
||||
binding.lifecycleOwner = this
|
||||
binding.model = model
|
||||
|
||||
binding.ok.setOnClickListener {
|
||||
isCancelable = false
|
||||
binding.progress.visibility = View.VISIBLE
|
||||
binding.controls.visibility = View.GONE
|
||||
|
||||
model.deleteCollection().observe(viewLifecycleOwner, Observer { exception ->
|
||||
if (exception != null)
|
||||
parentFragmentManager.beginTransaction()
|
||||
.add(ExceptionInfoFragment.newInstance(exception, model.account), null)
|
||||
.commit()
|
||||
dismiss()
|
||||
})
|
||||
}
|
||||
|
||||
binding.cancel.setOnClickListener {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) =
|
||||
DeleteCollectionLoader(activity!!, account, collectionInfo)
|
||||
class DeleteCollectionModel(
|
||||
application: Application
|
||||
): AndroidViewModel(application) {
|
||||
|
||||
override fun onLoadFinished(loader: Loader<Exception>, exception: Exception?) {
|
||||
dismiss()
|
||||
var account: Account? = null
|
||||
var collectionInfo: Collection? = null
|
||||
|
||||
if (exception != null)
|
||||
requireFragmentManager().beginTransaction()
|
||||
.add(ExceptionInfoFragment.newInstance(exception, account), null)
|
||||
.commit()
|
||||
else
|
||||
(activity as? AccountActivity)?.reload()
|
||||
}
|
||||
val db = AppDatabase.getInstance(application)
|
||||
|
||||
override fun onLoaderReset(loader: Loader<Exception>) {}
|
||||
val confirmation = MutableLiveData<Boolean>()
|
||||
val result = MutableLiveData<Exception>()
|
||||
|
||||
@MainThread
|
||||
fun initialize(account: Account, collectionId: Long) {
|
||||
if (this.account == null)
|
||||
this.account = account
|
||||
|
||||
class DeleteCollectionLoader(
|
||||
context: Context,
|
||||
val account: Account,
|
||||
val collectionInfo: CollectionInfo
|
||||
): AsyncTaskLoader<Exception>(context) {
|
||||
if (collectionInfo == null)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
collectionInfo = db.collectionDao().get(collectionId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartLoading() = forceLoad()
|
||||
fun deleteCollection(): LiveData<Exception> {
|
||||
viewModelScope.launch(Dispatchers.IO + NonCancellable) {
|
||||
val account = account ?: return@launch
|
||||
val collectionInfo = collectionInfo ?: return@launch
|
||||
|
||||
override fun loadInBackground(): Exception? {
|
||||
Settings.getInstance(context)?.use { settings ->
|
||||
HttpClient.Builder(context, settings, AccountSettings(context, settings, account))
|
||||
val context = getApplication<Application>()
|
||||
HttpClient.Builder(context, AccountSettings(context, account))
|
||||
.setForeground(true)
|
||||
.build().use { httpClient ->
|
||||
try {
|
||||
val collection = DavResource(httpClient.okHttpClient, HttpUrl.parse(collectionInfo.url)!!)
|
||||
try {
|
||||
val collection = DavResource(httpClient.okHttpClient, collectionInfo.url)
|
||||
|
||||
// delete collection from server
|
||||
collection.delete(null)
|
||||
// delete collection from server
|
||||
collection.delete(null) {}
|
||||
|
||||
// delete collection locally
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.writableDatabase
|
||||
db.delete(ServiceDB.Collections._TABLE, "${ServiceDB.Collections.ID}=?", arrayOf(collectionInfo.id.toString()))
|
||||
// delete collection locally
|
||||
db.collectionDao().delete(collectionInfo)
|
||||
|
||||
// return success
|
||||
result.postValue(null)
|
||||
|
||||
} catch(e: Exception) {
|
||||
// return error
|
||||
result.postValue(e)
|
||||
}
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ConfirmDeleteCollectionFragment: DialogFragment() {
|
||||
|
||||
companion object {
|
||||
|
||||
fun newInstance(account: Account, collectionInfo: CollectionInfo): DialogFragment {
|
||||
val frag = ConfirmDeleteCollectionFragment()
|
||||
val args = Bundle(2)
|
||||
args.putParcelable(ARG_ACCOUNT, account)
|
||||
args.putSerializable(ARG_COLLECTION_INFO, collectionInfo)
|
||||
frag.arguments = args
|
||||
return frag
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val collectionInfo = arguments!!.getSerializable(ARG_COLLECTION_INFO) as CollectionInfo
|
||||
val name = if (collectionInfo.displayName.isNullOrBlank())
|
||||
collectionInfo.url
|
||||
else
|
||||
collectionInfo.displayName
|
||||
|
||||
return AlertDialog.Builder(activity!!)
|
||||
.setTitle(R.string.delete_collection_confirm_title)
|
||||
.setMessage(getString(R.string.delete_collection_confirm_warning, name))
|
||||
.setPositiveButton(android.R.string.yes, { _, _ ->
|
||||
val frag = DeleteCollectionFragment()
|
||||
frag.arguments = arguments
|
||||
frag.show(fragmentManager, null)
|
||||
})
|
||||
.setNegativeButton(android.R.string.no, { _, _ ->
|
||||
dismiss()
|
||||
})
|
||||
.create()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -12,10 +12,10 @@ import android.accounts.Account
|
||||
import android.app.Dialog
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.DialogFragment
|
||||
import android.support.v7.app.AlertDialog
|
||||
import at.bitfire.dav4android.exception.HttpException
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.davdroid.R
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import java.io.IOException
|
||||
|
||||
class ExceptionInfoFragment: DialogFragment() {
|
||||
@@ -45,17 +45,17 @@ class ExceptionInfoFragment: DialogFragment() {
|
||||
else -> R.string.exception
|
||||
}
|
||||
|
||||
val dialog = AlertDialog.Builder(requireActivity())
|
||||
val dialog = MaterialAlertDialogBuilder(requireActivity())
|
||||
.setIcon(R.drawable.ic_error_dark)
|
||||
.setTitle(title)
|
||||
.setMessage(exception::class.java.name + "\n" + exception.localizedMessage)
|
||||
.setNegativeButton(R.string.exception_show_details, { _, _ ->
|
||||
.setNegativeButton(R.string.exception_show_details) { _, _ ->
|
||||
val intent = Intent(activity, DebugInfoActivity::class.java)
|
||||
intent.putExtra(DebugInfoActivity.KEY_THROWABLE, exception)
|
||||
account?.let { intent.putExtra(DebugInfoActivity.KEY_ACCOUNT, it) }
|
||||
startActivity(intent)
|
||||
})
|
||||
.setPositiveButton(android.R.string.ok, { _, _ -> })
|
||||
}
|
||||
.setPositiveButton(android.R.string.ok) { _, _ -> }
|
||||
.create()
|
||||
isCancelable = false
|
||||
return dialog
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.text.TextUtils
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.TextView
|
||||
import at.bitfire.davdroid.model.HomeSet
|
||||
|
||||
class HomeSetAdapter(
|
||||
context: Context
|
||||
): ArrayAdapter<HomeSet>(context, android.R.layout.simple_list_item_2, android.R.id.text1) {
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val data = getItem(position)!!
|
||||
|
||||
val v: View
|
||||
if (!data.displayName.isNullOrBlank()) {
|
||||
v = convertView ?: LayoutInflater.from(context).inflate(android.R.layout.simple_list_item_2, null, false)
|
||||
v.findViewById<TextView>(android.R.id.text1).text = data.displayName
|
||||
v.findViewById<TextView>(android.R.id.text2).apply {
|
||||
text = data.url.toString()
|
||||
setSingleLine()
|
||||
ellipsize = TextUtils.TruncateAt.START
|
||||
}
|
||||
} else {
|
||||
v = convertView ?: LayoutInflater.from(context).inflate(android.R.layout.simple_list_item_1, null, false)
|
||||
v.findViewById<TextView>(android.R.id.text1).apply {
|
||||
text = data.url.toString()
|
||||
setSingleLine()
|
||||
ellipsize = TextUtils.TruncateAt.START
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup) =
|
||||
getView(position, convertView, parent)
|
||||
|
||||
}
|
||||
@@ -9,13 +9,13 @@
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
|
||||
interface IAccountsDrawerHandler {
|
||||
|
||||
fun onSettingsChanged(settings: ISettings?, menu: Menu)
|
||||
fun initMenu(context: Context, menu: Menu)
|
||||
|
||||
fun onNavigationItemSelected(activity: Activity, item: MenuItem): Boolean
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import android.app.NotificationChannelGroup
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.R
|
||||
|
||||
@@ -24,6 +24,7 @@ object NotificationUtils {
|
||||
const val NOTIFY_EXTERNAL_FILE_LOGGING = 1
|
||||
const val NOTIFY_REFRESH_COLLECTIONS = 2
|
||||
const val NOTIFY_SYNC_ERROR = 10
|
||||
const val NOTIFY_INVALID_RESOURCE = 11
|
||||
const val NOTIFY_OPENTASKS = 20
|
||||
const val NOTIFY_PERMISSIONS = 21
|
||||
const val NOTIFY_LICENSE = 100
|
||||
@@ -34,8 +35,8 @@ object NotificationUtils {
|
||||
|
||||
private const val CHANNEL_SYNC = "sync"
|
||||
const val CHANNEL_SYNC_ERRORS = "syncProblems"
|
||||
const val CHANNEL_SYNC_WARNINGS = "syncWarnings"
|
||||
const val CHANNEL_SYNC_IO_ERRORS = "syncIoErrors"
|
||||
const val CHANNEL_SYNC_STATUS = "syncStatus"
|
||||
|
||||
|
||||
fun createChannels(context: Context) {
|
||||
@@ -43,26 +44,29 @@ object NotificationUtils {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
val syncChannelGroup = NotificationChannelGroup(CHANNEL_SYNC, context.getString(R.string.notification_channel_sync))
|
||||
nm.createNotificationChannelGroup(syncChannelGroup)
|
||||
val syncChannels = arrayOf(
|
||||
NotificationChannel(CHANNEL_SYNC_ERRORS, context.getString(R.string.notification_channel_sync_errors), NotificationManager.IMPORTANCE_DEFAULT),
|
||||
NotificationChannel(CHANNEL_SYNC_IO_ERRORS, context.getString(R.string.notification_channel_sync_io_errors), NotificationManager.IMPORTANCE_MIN),
|
||||
NotificationChannel(CHANNEL_SYNC_STATUS, context.getString(R.string.notification_channel_sync_status), NotificationManager.IMPORTANCE_MIN)
|
||||
)
|
||||
syncChannels.forEach {
|
||||
it.group = CHANNEL_SYNC
|
||||
}
|
||||
nm.createNotificationChannelGroup(NotificationChannelGroup(CHANNEL_SYNC, context.getString(R.string.notification_channel_sync)))
|
||||
|
||||
nm.createNotificationChannels(listOf(
|
||||
NotificationChannel(CHANNEL_DEBUG, context.getString(R.string.notification_channel_debugging), NotificationManager.IMPORTANCE_HIGH),
|
||||
NotificationChannel(CHANNEL_GENERAL, context.getString(R.string.notification_channel_general), NotificationManager.IMPORTANCE_DEFAULT),
|
||||
*syncChannels
|
||||
|
||||
NotificationChannel(CHANNEL_SYNC_ERRORS, context.getString(R.string.notification_channel_sync_errors), NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||
description = context.getString(R.string.notification_channel_sync_errors_desc)
|
||||
group = CHANNEL_SYNC
|
||||
},
|
||||
NotificationChannel(CHANNEL_SYNC_WARNINGS, context.getString(R.string.notification_channel_sync_warnings), NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = context.getString(R.string.notification_channel_sync_warnings_desc)
|
||||
group = CHANNEL_SYNC
|
||||
},
|
||||
NotificationChannel(CHANNEL_SYNC_IO_ERRORS, context.getString(R.string.notification_channel_sync_io_errors), NotificationManager.IMPORTANCE_MIN).apply {
|
||||
description = context.getString(R.string.notification_channel_sync_io_errors_desc)
|
||||
group = CHANNEL_SYNC
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fun newBuilder(context: Context, channel: String = CHANNEL_GENERAL): NotificationCompat.Builder {
|
||||
fun newBuilder(context: Context, channel: String): NotificationCompat.Builder {
|
||||
val builder = NotificationCompat.Builder(context, channel)
|
||||
.setColor(context.resources.getColor(R.color.primaryColor))
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
|
||||
class PermissionsActivity: AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (savedInstanceState == null)
|
||||
supportFragmentManager.beginTransaction()
|
||||
.add(android.R.id.content, PermissionsFragment())
|
||||
.commit()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.PackageChangedReceiver
|
||||
import at.bitfire.davdroid.PermissionUtils.CALENDAR_PERMISSIONS
|
||||
import at.bitfire.davdroid.PermissionUtils.CONTACT_PERMSSIONS
|
||||
import at.bitfire.davdroid.PermissionUtils.TASKS_PERMISSIONS
|
||||
import at.bitfire.davdroid.PermissionUtils.havePermissions
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.databinding.ActivityPermissionsBinding
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
|
||||
class PermissionsFragment: Fragment() {
|
||||
|
||||
lateinit var model: Model
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
model = ViewModelProvider(this).get(Model::class.java)
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
val binding = ActivityPermissionsBinding.inflate(inflater, container, false)
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
binding.model = model
|
||||
|
||||
binding.text.text = getString(R.string.permissions_text, getString(R.string.app_name))
|
||||
|
||||
model.needContactsPermissions.observe(viewLifecycleOwner, Observer { needContacts ->
|
||||
if (needContacts && model.haveContactsPermissions.value == false)
|
||||
requestPermissions(CONTACT_PERMSSIONS, 0)
|
||||
})
|
||||
model.needCalendarPermissions.observe(viewLifecycleOwner, Observer { needCalendars ->
|
||||
if (needCalendars && model.haveCalendarPermissions.value == false)
|
||||
requestPermissions(CALENDAR_PERMISSIONS, 0)
|
||||
})
|
||||
model.needTasksPermissions.observe(viewLifecycleOwner, Observer { needTasks ->
|
||||
if (needTasks == true && model.haveTasksPermissions.value == false)
|
||||
requestPermissions(TASKS_PERMISSIONS, 0)
|
||||
})
|
||||
model.needAllPermissions.observe(viewLifecycleOwner, Observer { needAll ->
|
||||
if (needAll && model.haveAllPermissions.value == false) {
|
||||
val all = CONTACT_PERMSSIONS + CALENDAR_PERMISSIONS +
|
||||
if (model.haveTasksPermissions.value != null) TASKS_PERMISSIONS else emptyArray()
|
||||
requestPermissions(all, 0)
|
||||
}
|
||||
})
|
||||
|
||||
binding.appSettings.setOnClickListener {
|
||||
val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", BuildConfig.APPLICATION_ID, null))
|
||||
if (intent.resolveActivity(requireActivity().packageManager) != null)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
model.checkPermissions()
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
model.checkPermissions()
|
||||
}
|
||||
|
||||
|
||||
class Model(app: Application): AndroidViewModel(app) {
|
||||
|
||||
val haveContactsPermissions = MutableLiveData<Boolean>()
|
||||
val needContactsPermissions = MutableLiveData<Boolean>()
|
||||
val haveCalendarPermissions = MutableLiveData<Boolean>()
|
||||
val needCalendarPermissions = MutableLiveData<Boolean>()
|
||||
|
||||
val haveTasksPermissions = MutableLiveData<Boolean>()
|
||||
val needTasksPermissions = MutableLiveData<Boolean>()
|
||||
val tasksWatcher = object: PackageChangedReceiver(app) {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
checkPermissions()
|
||||
}
|
||||
}
|
||||
|
||||
val haveAllPermissions = MutableLiveData<Boolean>()
|
||||
val needAllPermissions = MutableLiveData<Boolean>()
|
||||
|
||||
init {
|
||||
checkPermissions()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
tasksWatcher.close()
|
||||
}
|
||||
|
||||
fun checkPermissions() {
|
||||
val contactPermissions = havePermissions(getApplication(), CONTACT_PERMSSIONS)
|
||||
haveContactsPermissions.value = contactPermissions
|
||||
needContactsPermissions.value = contactPermissions
|
||||
|
||||
val calendarPermissions = havePermissions(getApplication(), CALENDAR_PERMISSIONS)
|
||||
haveCalendarPermissions.value = calendarPermissions
|
||||
needCalendarPermissions.value = calendarPermissions
|
||||
|
||||
val tasksAvailable = LocalTaskList.tasksProviderAvailable(getApplication())
|
||||
var tasksPermissions: Boolean? = null
|
||||
if (tasksAvailable) {
|
||||
tasksPermissions = havePermissions(getApplication(), TASKS_PERMISSIONS)
|
||||
haveTasksPermissions.value = tasksPermissions
|
||||
needTasksPermissions.value = tasksPermissions
|
||||
} else {
|
||||
haveTasksPermissions.value = null
|
||||
needTasksPermissions.value = null
|
||||
}
|
||||
|
||||
val allPermissions = contactPermissions && calendarPermissions && (!tasksAvailable || tasksPermissions == true)
|
||||
haveAllPermissions.value = allPermissions
|
||||
needAllPermissions.value = allPermissions
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Loaded 100 of 350 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user